From 0c7649a13b4a7fd871f4d5064896cd839233583d Mon Sep 17 00:00:00 2001 From: Aleksejs Kovalovs Date: Sun, 4 Oct 2026 15:19:52 +0300 Subject: [PATCH] Shared "Topics to discuss" list (#206) The first card on a meeting is a list either side adds to at any time, visible to the other at once with no draft phase. The author edits or deletes a topic, either side ticks it as discussed, and it is frozen once archived. - Anketa::$topicsBlob/$topicsVersion (SQLite and MySQL migrations), encrypted under the meeting key, saved by PUT /api/anketas/{id}/topics through the same conditional UPDATE as the "discussed" ticks. - Undiscussed topics carry forward like unchecked outcomes. The archive request names the topics version they were built from, and is refused with the meeting left open if the list has moved on. - Form version 3: new meetings on every built-in template drop the "What else to discuss" blocks; older meetings keep them. Company templates keep both ids valid. - Demo fixture regenerated for all 6 locales with topics. See docs/decisions/2026-10-04-shared-topics-list.md. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 + README.md | 4 +- backend/fixtures/demo-seed.json | 362 +++++++------ .../Version20261004104608.php | 30 ++ backend/migrations/Version20261004104608.php | 41 ++ backend/src/Anketa/AnketaLifecycleService.php | 42 +- backend/src/Anketa/AnketaPresenter.php | 7 +- .../Anketa/AnketaTopicsChangedException.php | 22 + backend/src/Command/ResetDemoDataCommand.php | 4 +- backend/src/Controller/AnketaController.php | 69 ++- backend/src/Dto/ArchiveAnketaRequest.php | 9 + backend/src/Dto/CreateAnketaRequest.php | 3 + backend/src/Entity/Anketa.php | 36 +- backend/src/Repository/AnketaRepository.php | 47 +- .../SerializationBoundaryTest.php | 1 + .../tests/Functional/AnketaControllerTest.php | 220 ++++++++ .../tests/Functional/PrivacyBlackBoxTest.php | 9 + .../Repository/AnketaRepositoryTest.php | 93 ++++ .../Functional/ResetDemoDataCommandTest.php | 7 + .../Anketa/AnketaLifecycleServiceTest.php | 5 + .../tests/Unit/Anketa/AnketaPresenterTest.php | 5 + .../tests/Unit/TranslationConsistencyTest.php | 2 + backend/translations/messages.de.yaml | 1 + backend/translations/messages.en.yaml | 1 + backend/translations/messages.es.yaml | 1 + backend/translations/messages.fr.yaml | 1 + backend/translations/messages.lv.yaml | 1 + backend/translations/messages.ru.yaml | 1 + .../2026-10-04-shared-topics-list.md | 204 ++++++++ docs/decisions/README.md | 1 + docs/encryption.md | 4 +- docs/history.md | 1 + docs/methodology.md | 8 +- docs/user-flow.md | 8 +- frontend/e2e/admin-templates.spec.ts | 8 +- frontend/e2e/dual-actor-anketa.spec.ts | 244 ++++++++- frontend/e2e/helpers/provision.ts | 14 + frontend/scripts/generate-demo-fixture.mjs | 54 +- frontend/scripts/generate-doc-screenshots.mjs | 21 +- frontend/scripts/topicsCard.mjs | 36 ++ frontend/src/admin/templateEditor.test.ts | 54 +- frontend/src/admin/templateEditor.ts | 21 +- frontend/src/anketa/AnketaTopics.svelte | 488 ++++++++++++++++++ frontend/src/anketa/blobSync.ts | 48 ++ frontend/src/anketa/discussedSync.ts | 20 +- frontend/src/anketa/outcomes.ts | 20 +- frontend/src/anketa/questions.test.ts | 60 ++- frontend/src/anketa/questions.ts | 44 +- .../src/anketa/templateDefinition.test.ts | 11 +- frontend/src/anketa/topics.test.ts | 229 ++++++++ frontend/src/anketa/topics.ts | 148 ++++++ frontend/src/anketa/topicsSync.test.ts | 388 ++++++++++++++ frontend/src/anketa/topicsSync.ts | 194 +++++++ frontend/src/api/types.ts | 4 + frontend/src/i18n/locales/de.json | 6 + frontend/src/i18n/locales/en.json | 6 + frontend/src/i18n/locales/es.json | 6 + frontend/src/i18n/locales/fr.json | 6 + frontend/src/i18n/locales/lv.json | 6 + frontend/src/i18n/locales/ru.json | 6 + frontend/src/pages/AccountSettings.svelte | 4 + frontend/src/pages/Anketa.svelte | 181 ++++++- frontend/src/pages/CreateAnketa.svelte | 11 +- 63 files changed, 3278 insertions(+), 312 deletions(-) create mode 100644 backend/migrations-mysql/Version20261004104608.php create mode 100644 backend/migrations/Version20261004104608.php create mode 100644 backend/src/Anketa/AnketaTopicsChangedException.php create mode 100644 docs/decisions/2026-10-04-shared-topics-list.md create mode 100644 frontend/scripts/topicsCard.mjs create mode 100644 frontend/src/anketa/AnketaTopics.svelte create mode 100644 frontend/src/anketa/topics.test.ts create mode 100644 frontend/src/anketa/topics.ts create mode 100644 frontend/src/anketa/topicsSync.test.ts create mode 100644 frontend/src/anketa/topicsSync.ts diff --git a/CLAUDE.md b/CLAUDE.md index df67940e..f1550b1e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,6 +82,8 @@ Opt-in "Remember this browser for 30 days" ([GitHub issue #205](https://github.c Everything since v1.6.0 was released together as v1.7.0: the paragraphs above from business-day reminders to "Remember this browser", plus the user-facing rename of "anketa" to "1:1" in all 6 locales, emails and docs ([#195](https://github.com/aleksejs1/encrypted1on1/issues/195), [#196](https://github.com/aleksejs1/encrypted1on1/issues/196); keys, identifiers and `/anketas` routes keep the old word) and three first-use fixes ([#197](https://github.com/aleksejs1/encrypted1on1/issues/197)–[#199](https://github.com/aleksejs1/encrypted1on1/issues/199): an empty-list first-step card, no default role on the create form, an "all fields are optional" line). +Shared "Topics to discuss" list ([GitHub issue #206](https://github.com/aleksejs1/encrypted1on1/issues/206), the last part of #213), not yet released: the first card on a meeting is a list either side adds to at any time, visible to the other at once with no draft phase; the author edits or deletes a topic, either side ticks it as discussed, and it's frozen once archived. `Anketa::$topicsBlob`/`$topicsVersion` (a migration), encrypted under anketaKey, saved by `PUT /api/anketas/{id}/topics` through the same conditional `UPDATE` as the "discussed" ticks. The client is `anketa/topics.ts` (pure list operations), `anketa/topicsSync.ts` (one `TopicsSync` per loaded anketa, like `DiscussedSync`: one save at a time, each change with its own promise) and `anketa/AnketaTopics.svelte`. Undiscussed topics carry forward like unchecked outcomes, re-encrypted by the archiving browser; the archive request names the `topicsVersion` they were built from, and `markArchivedIfOpen()` refuses it (`AnketaTopicsChangedException`, meeting left open) if the list has moved on, after which the page archives again with the current list. `Anketa::CURRENT_FORM_VERSION` is 3: from it `getQuestionsForSide()` drops `discuss`/`managerDiscuss` for every built-in template, so older meetings keep the blocks and their answers. Both ids stay valid in company templates and in the editor's "add" menu (hiding them there was tried and dropped: a removed block couldn't be put back); only the new-template prefill leaves them out. The demo fixture carries topics instead of the "what else" entries. See `docs/decisions/2026-10-04-shared-topics-list.md`, including its accepted limitations, before changing the save path. + **For the full phase-by-phase and decision-by-decision history — what was built, why, what was tried and rejected, and exactly how each change was verified against a real running stack — see [`docs/history.md`](docs/history.md).** That file carries the detailed record this section used to hold directly; it was split out so a session doesn't have to load the entire development history into context to find the handful of constraints below that are actually still load-bearing. `docs/adr/` holds the ~8 genuinely foundational, hard-to-reverse architecture decisions; `docs/decisions/` holds shorter records of non-trivial decisions made after the split — check both before re-deriving a decision that's already been made. The detailed spec currently lives outside this repo as a local working document (not tracked in git); an English version is planned to land here (likely `docs/SPEC.md`) as a separate task. Until that exists, treat the constraints below as authoritative, and ask before assuming anything not covered here. diff --git a/README.md b/README.md index b5af0867..fb0f2aaa 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,10 @@ More: [screenshots](docs/screenshots/) — login, the 1:1 list, an empty vs fill ## Status Production-ready and feature-complete (v1.7.0): -- **End-to-end encrypted 1:1 cycles:** role-specific questions for employees and managers, Markdown-formatted answers with strict sanitization, private drafts, silent in-place editing, threaded comments, shared checklist outcomes with author ownership, and goal tracking with encrypted progress checkpoints. +- **End-to-end encrypted 1:1 cycles:** role-specific questions for employees and managers, Markdown-formatted answers with strict sanitization, private drafts, silent in-place editing, a shared "Topics to discuss" list visible before either side publishes, threaded comments, shared checklist outcomes with author ownership, and goal tracking with encrypted progress checkpoints. - **Private meeting notes:** an author-scoped encrypted notepad on every 1:1 (sticky side-column on desktop, card on mobile) that neither counterparts, server operators, nor admins can read. Includes background autosave, multi-tab conflict resolution, "Hide notes" for screen-sharing privacy, and inclusion in user data exports. - **Meeting templates & custom library:** 4 built-in templates (Regular check-in, First 1:1 / Onboarding, Career growth, Support & workload check-in) plus company-authored custom templates created by admins in an interactive editor; immutable template versioning; next-meeting type picker at archive; ad-hoc one-off meetings that don't fork recurring chains or duplicate carry-forward items; automatic safe recurrence fallback to regular check-ins; company templates can be exported, imported and shared as a file or a link. -- **Live in-meeting collaboration:** real-time background sync during meetings so counterpart answer edits, new comments (with ARIA live announcements), outcomes, and checkpoints appear automatically without page reloads; either participant can tick questions off as discussed, dimming them on both screens as an encrypted, size-padded agenda tracker. +- **Live in-meeting collaboration:** real-time background sync during meetings so counterpart answer edits, new comments (with ARIA live announcements), topics, outcomes, and checkpoints appear automatically without page reloads; either participant can tick questions off as discussed, dimming them on both screens as an encrypted, size-padded agenda tracker. - **Streamlined read-only view:** clean display for published and archived 1:1s showing only answered questions and selected choices rather than long lists of empty fields or disabled controls. - **Analytics & reports:** grouped 1:1 list by date or counterpart, mood/workload sparklines, upcoming meeting countdown badges, a calm "not closed" prompt for a meeting past its date, a permanent per-pair link for calendar events, client-side cross-period growth reports, and company admin adoption reports. - **Security & resilience:** zero-knowledge ciphertext storage, drafts encrypted with keys derived from private keys (surviving password changes), forgotten-password recovery with keypair regeneration and counterpart re-sharing, atomic archiving (no duplicate successors), rate limiting, CSP+SRI hardening with explicit HSTS, and automated privacy test gates. diff --git a/backend/fixtures/demo-seed.json b/backend/fixtures/demo-seed.json index 1dac34e8..947cfdd2 100644 --- a/backend/fixtures/demo-seed.json +++ b/backend/fixtures/demo-seed.json @@ -1,25 +1,25 @@ { - "generatedAt": "2026-09-10T02:07:34.132Z", + "generatedAt": "2026-10-04T11:07:47.706Z", "password": "e1o1-demo-2026", "locales": { "en": { "employee": { "email": "demo-employee@example.com", "name": "Alex Morgan", - "id": "01a0890f-09c7-78f0-887a-4a9574df8fbf", + "id": "01a10697-f648-7686-a3bf-74b112ab2a38", "authHash": "1tmBYNuLy12dxJwUZL95K3HJLBSA18/wKLriBsLnHPE=", - "publicKey": "iJ5QhiivUrGenv3vpZVX8Vqcb/XgYjViFEtMK8hVgBY=", - "encryptedPrivateKey": "3oSHU9Y8pqhdQ+nHxXT8Y4u7heDu22ke7opSMijooKnFKROgIX+BGDrW2aprjGQSjaYelkP9uxLcMID7553fL0Cf/1gn6YE0" + "publicKey": "hEALqEuGUK+ob2uZajldWoN7NzTDn4vDSCiwZUoMxHU=", + "encryptedPrivateKey": "7LDQumHY2AZwZZNwtUBae/iRrkpZTZVxE1NIHZuO4+YxctQGUUW9YEIFArT8/WhfwD/zcx4ZozGQKlF0/6q9dUiIJrl2/kQ1" }, "manager": { "email": "demo-manager@example.com", "name": "Jordan Blake", - "id": "01a0890f-0b74-7134-8f6b-9762371bcf1d", + "id": "01a10697-f86c-79b7-9c23-0f8444dfe23c", "authHash": "aajR4EGC9ebpuhA518U21LdTFxZo05P06Q79rKoadF4=", - "publicKey": "J37Tz2l8p1qHX+oMxfPrShVh3VBBptFwlgdSV1cdGiY=", - "encryptedPrivateKey": "g1FbJ8SU5E8zBfJYpPxt2MDo7ItD44h1H1vZSxW+cQOOegTtLb9/XQeXZhryRXL0YgxY5fgG3Kj5Y3fC27lsw0lM6EHugieB" + "publicKey": "r4A1C1hN2bX3pXPeJM6xN9x8hdO+j0TveqfcUN6AwWU=", + "encryptedPrivateKey": "ZRi36KqUR0lu1BLVxqDgbENED+ikgn8tpL2rBx6yiJMBE6alAykJSmjTUKCOn3IFqawy7RZENpppU9Bl30gXnSzlPC3HOSJz" }, - "goalUuid": "43bfb07d-abc2-41bb-b65b-49904440a4e5", + "goalUuid": "23da9a3b-1938-4a3d-8d01-ec91ce8df857", "goalTitle": "Lead one cross-team project end to end", "goalDescription": "Own scoping and delivery of a project spanning at least two teams.", "goalTargetDateOffsetMonths": 4, @@ -28,44 +28,50 @@ { "archived": true, "missed": false, - "employeeSealedKey": "hS/rS4/k3fumr0uLzmiHb8czu7fQbyRRKpQmFYMvK3g00ZLhxQ8dRVT7AD/K/zL8zZKg473qz83Q1dOqquwOANS7mOPMDcaPO0W5stBxC9Y=", - "managerSealedKey": "/aXliSGCuFRPOoDDzF8TxAsc2HyHlWRfAlxtbH9nDyQ8L25aNfgVy8Aby4P11K3Pb11iiT/bTOaoJKz2+XGB16d0bvrRhRP37j7kdmAAyeI=", - "employeeBlob": "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", - "managerBlob": "H2Jvs7xxxT8zB9ZAWksuVQ10Kel7zPv3vOfV3fTZCv4d1q3eKnUhg1HRr7KyjzrLcn/ySeYzk+zzgJXcvApDe7HrpnMDC8iTAW2/U4gnEPqYSM3KRzWliHj8ILYDC95Xm5kG+vig1rUEwQPI2aUNkfbsJ01CeDxPKDjeNxFD+zDNOqWOR2w18eBmRC8Vqs9ZImfIy61RAKVofhtS6ZFCFkf+j9u1zzdfBTuHlCsJhMjnd57fJ+i+fOwfjLvz+x8lNjPFFWM/VIHx/9CVe3uCKUTTcyG/aTRajqVUFmSDcwyDkmgQfgdW78WadfqUxL/fNeZsA0lM3Q6nV8MxTNeizAorAT+iakyC5IzyIOkCPKHUZWHYrePeD3olL42KV4Gk7ZoZhJl4OYjfzCB/62GKJym3rXI3KWSeIs17UdwiZ+3eRuczraov2oAtaxNExNLNo/UPOYJsxLBQl+eZ6nQS1tQ/IxmjyETtuIOSoWKqwVyyP4va1drFqHysttb0aBUntPetj5+ecP4HyQtScOvuU3fwaLfrz1E7Dk/Olj5aW6PseIGLT+C+zb1TIGeJVnCcMufZ86bEbLBeA5v425ojH9Z/Gy7hUwaC1IPvwwcolS6QTQZaAk/Xprk/iIEQtMDvcSMXwB803Ms7CM1zLLCjrHuBd0MZed6m7EyCiDLJ0m7TBIDwo29k/RUxXFvDbMWNDhzJ5xxwn5Os6g1CeQ+CJUHVFgjPhVVoVwyZKiv6YvtXTgMQLh89PLP6Lvo2QDgk1v7XKv7jcNOUrlT99AGuiZRv5kbMfxlu0i/98n8JMJrUrSpfQygrqfOK7ATJflyqtWKCfA6+EPCxRIcxPQMkXDD7exZkxFBmnzWVprKu4wyVVqq4mTroK6zhSH0UPowNhuwAnNyM+FXqrSrP80Tw6A2UoUPxIX9aiaW3QugjYFtYXRA3wj1YvG0HcjKX22Y8vWELc27feNMuMmTF+kicSSa0P2GvRBrlLHgF7rrEPTxqUh4AAGGC32+ReXvHEZz9AY8+3QbC9lBl3+88BLte/EN0B4R1dx5LyG8OEawdMKZRfdsU9FLTohaLWrX7I/FPb4OSwNh6e77BIclzyIv4X8XR6qOTMlme84Lnove63ZMVH/t8Nt3Mt2jLIdtPZZGQmILUeV13CTabFaoUrHqg6ztC5ACIpB8/M6dDJtG3h4lED2CTwWRNgANdS0QdHVDWEqHfnmh/nz1m5JbqptHWak+xJsLSe8gchgIGT7vlThazNHRdmOtaDiQYXMUubF5/XRK9/A2s9IaGSCqqUGMwzJhUDK+5o8txQaGMdbbtg2Cq2OTJQmRL2zfz9dqk+Lkav5AW6ztEeEbkG982zOA0IR7KHpvbmEuCFTGDKijJkhox4GDqqOmw2Avx75EeRX7glbN3Tkt2QMi9PcC+6yvNXQkEAij+6AIilwzNUCw4ZinykQi/i2ZJp3VgScUWr85OL0o=", - "commentsBlob": "9rtmlOyjRuCUqkfQCjaW3XdKQy14AS3W/tXBgijoFzqlLzYtvAYBM8pp2x6kFcw5U/ob0+3TUXCLV2T1Kl6+4/V45ObaMy2DUUPqzqsKCaLJw+EDYgO8zZGy416osdWE13PRnVz0Iq07/zp8qhPW2DHDleGynazrgsiLJFIFeDvB1bvgnwjW0UKdmvX5sae7mmPaMsLuFKgFlAtAl9w5LJFoixCQXhSoSwDRU2cIxZQ7g+oZIdzGwh9PtYX0wHGugSsZJQ2ZpfKrgWw5VwU4JRxpciNnJOGYVtVcjmkYRXSF1OD7hABvArahfZmUew5hLv7okqx7+zmMAkCZyIa6y86oMhcAk33kP/uZasbXbBx8pdeM4jVXMtrU/1G/Szc0pLeRBArkUAqiNeoSMKLDWWBEjC7vPfWA0qdj8Bl+rE0CDiHkg5lP/fV4Wf1hsQd/f5kL/a3EmmXRsxQX", + "employeeSealedKey": "vDmP96rvJ75zWCOI8yg8UDhH3WrPFLCRKiZQjqkv3RnCfBcXxIK4fEvIZXkma2qOmnSFBCI86ylZXxTr8ga2d/0RRE+/znGSdX848wzt3r8=", + "managerSealedKey": "MvzntrhlEr96kqEC6KfagsKUv7NjOvMClQhK7slBsRi6PTn45PSEph4BfYuq8fLybBEq2r/UFz66Rfnfk0P2qHQ51mluZs6Cg/Git40bHpg=", + "employeeBlob": "HqkkfcD0rnt0mcjw3Y6+UYOEHqUrbJ1uFQmLZ+nuOSUqXAEUGnoJBFufA60M4lwsURg0EdKW0AhNnUjW/jTYgJRYcC2q20xFM2S/WfUgqG7XRBd60n/6H55zOGR2M5I4YBmB1i2OBVMBsEpxLALRjGfUtAFoRZBWjdp3FCDSNqcK4g7/BkIIY6NcSXmPlEOwrOH3Hk+0JJ4bLRMAVO3i3tXWr0obpIZKSphOWEKfswd/YD6u0XboldQwMpC5uQQTw/rk0ef7gT1EE5vSLAGZYstsrjwM+0MUE3S3UUhAgMb/hHyYSpwtmmhlyCDawLEMMz0xrHvolN7OSvJ6APkO9OXLIU+jb9o6x4ireApb2Zwd0/qIkefPlsTPGOg/GkMVxjFE221pfl9pZve0t827pInEY3mI/fZlARuqADW6C1uQk/xMjXG/nXb2yPpNpat0dqAdjBLC7HuWmO5gEexEDrUR8cBrlqPJnLgmjt3j+opjkaLI0iETEf5o+LYOi+QlZ5Pj2+als/zGDSkA3vfU89PefuKolPJ4TuyqZAK558rT6MYLUSszBdskYZE9V9Yxqi+Az8uVMKqJsxW1oJpeT5FyEvCU4nPqcJBAmstSb4GYMd695kN0uUcs1uMDyC04QA7j66HM8Q6lfZ1XiLjmmpq7tjI5DoJB4cTmZlFs1gpZYLxeeuzhd5vqUcnqPHu7ugzPQ5LBO4rJZJVybH6ucjN6lRXJK67nngao7XrxmTaZNue9PW79s5UhXTptroeWxM4opKYd0TC0iyHp9SnWGu4Ii4nh9Xr4sx01VRbT62XvWD2CjQB6N1fDSxnqTf0mrx/Oc+fa7gIWVeuZJi9B/bWDZwDczwrduzrHCIOsprRRvovjQ8DiVwFGrrQpUv+yzGri/NHgo+giHVDfztbQCByByMkkfbYjrgvjT8+o7C/cR2VQ8bFGVDy8nth5TSz8nJev5mJi8NwJ9usA5yI7OVREKShNbkpTy8czzjQWWLzHcrcoqRm4alg7VR+BkWdHCTfWuxXTuspCAWzlF/Vr3FO2NbYDucAvSokVvYYY6YYitxD2BiIQW9uTijuLthPOGDNZ8sSJ+HUW8jFjCTcgc/QSySrn+bqRxZSWqhfzV/aHllRmxHuOxZ3b6r0mbsNjTCuL2LPY4eqtzYeYg7RuUAV4SjI/35hcVWOGKDLF7Seyu3KCl9ShM/fhXWPUBb6PWRd7fN40KEQecqpA+ddTjtqQi+wuH+itBg/E7f4OeVdAG/TfPjpgQcVxU4kL6MhYwdCEgNleFDkk84Eq1D6n1+1U1E8P5fqKiK/U4+4RdC5z07ahb+EMe7LlhX5fmuVE+HTrH84f98ZC8hdQbcmu+IVq88uPRK+720Grr89WH0YO6UmO2zhXT1lkKKMAx/O278TUpDJvxBjiPeob7P0d99r4dBYE7KLeQmlS45A8j2xig4Xcu+NB81FZ2j6Dn4MFFn4Ztslw34KExl3PEuYVMuZQB68nDdbSbU1C+2qvg5iyA7MC/+PvM9BvOFK07os23sRdXU+OHaIXcItthWsgOS2efVAfprt7P/bhNwcgc/782EYn98t8puc5bczo4n5rGXi7Hi01y1taGiSVGwFa58AiKrVt8n0mFaXiPv/uTnGIUwfv5CKj1tZQu/zNpQgAk+IQzjM4mnURfxhZOvL5FZhFVpKpbiwFdo7+966+zC88bwGHd/mb1bV3ejrt+yxyGQOsMLMxpxaU9L24O0NG5M3ARSlx7h/HN1Pj40xxjJcFe6olnQJj2iJEyl3bMLN0/R7hCJjBipVN5PezSZVeUNLj7hHhDX+4NpVkFLWr/RKSKye3mCpQ51Hc6WhHGjFHQOJdeN4NsVmfIHmj8zBVMT3Z0I2TrM1m1Pt6r5qPJ3LUB59gm7YMlK8Rau+Kz9r7rHOXyicIpRw9coMu8YbTayDTIx6wPoscDZKqg1tQMQfntWWHQKqeJ9x/Wp57pB2dYthn", + "managerBlob": "GJnZ/yGEpn2hI3kzvIq82aGKDPVNnxKXDrwHJiydF22cHt/kxNrvxHiZb9EavNdBiypl/xy3/DbEkg1Mn1Gfn7Tcw1SqmvoqFhs9fmNCvNhAc3x22ytilBJP/zlLqsYj8WIVIme7c48Y1RmE/bYueXkpAAUOp9aGn0CZR5b2gMAw4xpK3Uh2i6AzY9jnIPM9XRnH6aE6bN36nKJhNKCODQPuBNGXVV4AWuGGRgr4ffzzbMaBQmUk354MtsFJyWyEVuWVKzxDTgaFJBD+bKlR4RFc53KtQC7GpU82PBMCrbmQixMrrCAgYc81t+gEVlqqD7GYcMaSt+jPes8qdD49Y+Wn5mNRzGlwvKtnVBsORJE7EePbdYLQDOdRvD/lIDadyeXeUmYDPxgIlLjFda+QCkFqQOZ2hWsMvm8Yl86STd4bJvR6i84wbZ1geXlrYtrwL5MBiTQ9X4DaG+w5AXnYXPhagvV8UvrKr8akewbx1K65lricOVf7FSjm1ik+yD275AXo7Ve0zK/rHWfiWobQLWDbNmaOaJHCIfRtJZidD469VQBlddbbMDYX8MOcObvKC8ZRKeOgmDP4mWjfwvq0ldvC9Wt1gY3B1yd6ho6W9YpDRRTjBqcXpQVrdW0Nkd/AFRCOxuISUB3R1yg/JNDcLV8OYHmZKEBEHZJRoSjHAu+gw+zejQ1g7HigxJjgYHF+/p7HURp/t2vBRUo7lyu3Cdm7PbQBsOCyqwk2vPyhDHtMKa1a+FEJCeiKfz9o8Hi6E6KVEI2li519l1i7bbh0M8xHGqTPlcSHAMYjEnXlAjcXaPDnU3xHpfYcg2GTMg9EoStZGC+8TgFH5bq51CIKYXAdMlOMZjBc9i8z/8B9dlTOn4v+Wf4HcICc8f5J+501R+XPggR9gcgaWZFDoq9X5p/xfieWZ35F/Rd/FDfIMPK2V0/deDamRYXGCOVI4n04HxNjeOZWuAbxjESk5e5LbHi8SLB4qXm4zI8G9Bt570RpjlDUCbp9P0Z0G/43lC9s3msKs3XAwQb8oOfMmhEDZ7YZTtMGZRmjIkPBL3wqFBWtMFNBrxCHIDS2rBfJJx30LAmfIzOMEDF3KYTaIv7ZgyezfGMi92WvCV0BXjapS3yZvIRgfTB6E5LxVVyeN+VtRnHo5UJJcyJp8TG1ASjCp2brlobXixcKtIlqypj4QQ==", + "commentsBlob": "vWnUL0ehV2hcOd2ZEpatyD80aYExcTFCA9Atx6v+ugUTfTX+ONWQt3p1Vdw5M/h7DlMvJLOIdCu7W0pMetru/oy7gBq0iakHN6oV+jEhAPiUS1HRacK3wA9k4M5pHqWGpYCwDl18cNuVlJaIOpqydojzbFifIs10BKjIUBHFOxATjAuFF4UlknPhNy3n6nWQdqSszoARuLF/WuPBDWDlv0T9yB9czNgJRlcLkjQ1ikoXrCz2WaPjCNA3Vc2K+U96GwcbPx5mGiV2fmnof/7tfLNgdkjAiKhhhI+5/MEdtl71XtZvl/5A+wP7ngrSKIOFK84i6lmiK2adr1RyeFn+DLT2BBrsYyFGSQobJ1Ir9RmRbU4e9kNUD2brSfSoFFMqm/JVQdf8cTVarSf9iLNksgzyeUpAHP4/Qk/WUlDmje4PGITH6JtaPU3ixDYTplAioXil/hD8IGfzGG1/", "commentsVersion": 1, - "outcomesBlob": "pKKalgRhVN+8+VoCcEtruC6MbAZvPtJP3EfU9kCUYqU5c4T+CSaRJtBWzmIo8vCYCf0HI6zMm7qYMBeKQ3zY3SBJzLXKgEHiVsdWkoYr5lZbqLad5a2kfuAAVliOc++4OYYHlvvfxFdeK0m8IKRTwG6C3CbkeQ7HLQu3FLXbTSGq43XlD6INhp7ENTwUaL0wgrzdTKfiCJUiGL63iSaE5fdmYZjanPVOaxLU8LErzeLHsy1qbszWpntxd3EdvLmRaIn5oKaG57SYxDE+DHRBtl6RF7n3uKhyIXiztHi6d3V9mL/WWWb78OePf0m05X9Ewu3YSyiFpjBIpgWzg4e1mIATzXNiUUtximYhO4z81WX78H2D13tFrYgBBifqIiVsSl3ii27cFwYDVYpNWLZqyc2ZVDho", + "outcomesBlob": "zsnp4fannvtyg1VjlxFWMJ3qkIUuwcEjd6xmlACyfr0/GcJ2qPW36Uel1Q0D/B6zc2unVDTadHz1/uHpyx1cgo5j52HfBSWk4OHGpZbKJQnn0DdzUUho3+kyIi7VJcpzoyp5CpMvZgQYL+JqDNvhvarTvRAqn+x6ovt6VW2t3/wgAwkW3Rl85wnzz9I8jXL1JkUr6o5FMl0dy7P8P6s4tdcTV6Zin7bwfbqqrbp2ojEIkPmwpdYG5l0RFXtny1VapQR+Kd66xVFYixbHwipwMumY7cgmevVNiGjQPkAC7dvMTbciDNRpHiUY1B05mNPdY7Lxy8W2uI5WtHoy7pw+j1sJZn9pRyEU44+7oT28Yinu378+wvcBwC6dXiSwvxGn65cs05EGA2IYRdqxVtbqkjeOd3LL", "outcomesVersion": 1, - "goalCheckpointsBlob": "U2evyXJGO8InyPpvbC+3/gNAkE1O8oLZen0pLADBVmejyM3HbrD4EDOs9TJsKsT8PTfrkMUC0X0FOQiJ8x/lXjklqtJ7d9z+4oqaVD5m4e9VG+ZrXfu2xrFGb2ifb/awyGDjLfOwH/83n+YV2p3AK65R92Rx5xeNejJyN0/TYAxjU8Hae8UiRK6Yi6rskfzMe71c50UwgBH9wTqtUtEaRLxz2MPJ9orK/SUldNVr4nfJBXcpoX4oozchCVVSfMzMuR6zow+Jwq39mtjyGza67EQqcZ7wLuT8Mf8EcQj9pG/1jrSUQzMYF6/WMX8SJjA5G3mfRG4APZoAfCHTTwi1Do30x1dNQSL1J5bNotjzE+iWll9zOoobdFEqCZ92dDtnWkCySeJ/5HydzfeVfK5Q8yOC/E9kM81x9YQGs2T9+BnAmOyaRxmNCuLDnRu7yOqKdV4eVvljYYdcwnzKuUd5bNvl0VEoVAKIdu+kMICI9WlOgV4XBuSb2KUweGbp4gI=", - "goalCheckpointsVersion": 1 + "goalCheckpointsBlob": "PxhG+I55au+4uUW5SfbKgP3aNF+yko9vUtrVY2xnEtK6+Bg2qrSdXfHIdOn+WQTMK4uQCGCT5UwwnyV2VT2iXgsriudYQk7i7L68dn46gXbshWpxBE/v9L5oivPkJCgXIj0vw90ef16vdopuU4VuI9P4L7vOsJ9Yd+sSguQucoPZdCxyNZh5GwbiFcvOSNUsQ6wIAQfTOaVlEvQr+tF8tMUVi7vti3zDg3Noun3jdWQOEqitWnzQuMa+Jcvb9ccoOFpv+BCm70rotRuQqbfQ36jDuUr1rEGYftCNyfzNK2mEBMOxt+G+H92Oji2IL93HmvGfaYR2MF3/aquOAab6Vqo+uAhdwCuChdZMS0ocByGY4us0GXTvpff8O9ZV/5gU5qCBPEpq9m6EqfxbHGPHCnmQ4BBES2nvctT2sXKVFxcbtnW39CqR61/B4Yh353JUSBPFAjnlQ+z7p48oQqYP37p5GmAUrMdVCpTUrz05QazvIr9dYNMZidZfN4fme8s=", + "goalCheckpointsVersion": 1, + "topicsBlob": "/9O8Duslqmza4ZGVKfPLaoc7os1i9Q3QfA9cGNIPgSGqut3SoQhsMRug2eLfuVvysToee7vHQ0tMetlGwk0gOHggMYFnAv9/xG28dYfPC64GDyPLH96s60bU5fuhH5Ga+CA+qfC9cRRdiNQvfcslC4/xnU5LheO9oatWEgNtvjJyqN/72IaAkV6zwP/8ZNL3wnhLDExWjVDXatT64KVi0KTuwUaFUUIjhebRG8cH1XU1tNz37Kh4SYUERgYMkz3X2wPPVw3U/jSj8y85G93PWlcRmKkZ4JSbDhdbrpxLGy9qWqYixbvc+1/imUlrNbXdwDrBjNkRfjSEvqd9ua1LLDhfT9HCeiP+E1UqB99ceUB8kJXWRyRJULHSkhEq0Y14M+mcJ4k4GoUkq7cboEjsNObhY8r/S+/AF72oWWAvqZb6hS1LLw7M4ms23VbAj6Tbq6X4wPwJyXBSeLtSRjdQo8rOkaV35onAVBZ0gD3P8kDzUMFuIYUZS/y7ATS4vlxNUfUQxNe2FJLTaGpNDLRSK+qxwNlcF1cp5CilaL/gI0QqlEaSh3O2mQJbeCCVWoPxxZWPtSM201djcjswzV1/3QBqGv9WuthwBPiqnroEXdHptpy7Wk8dVKWerMkIPMl/bTnOVr5Y52KCjim+E975RwfNjqNRRXiW2S681VDob1+f8WiafBLGs8og4vBeDw5vB6dFP4ptEdnb131nmFsrO4NO6GZazfPjHsUEVTbQoUXTKhffvWu5KIho6M3H1FhzbSdiR7IjDBVdPjMrB93gSjA=", + "topicsVersion": 4 }, { "archived": true, "missed": false, - "employeeSealedKey": "ddPwgQ9dSLLaj1fLgN9y8/gDg3AygXaC4cviqLqsyk/gvRx0YcEehLijkt329UGw2C3BjhVccx4uAZ0fsmv6lIWd2cWueMD9GIcEj3VtTf0=", - "managerSealedKey": "nL8dsXBwIU9PGSCKJ6mZ8NNnSRr5OELQX0auLN4DZk1d6pzOjWGoAgbzC1HBqf1f40Pgiwz71a+fLYTNE5omXaCHf8lm9aFDyOKf7PwisA0=", - "employeeBlob": "n+59chWTdbTWS3YsobUO8ic5WFnCd9X0BlBQ8VdwEk9WpNnSNg/iFy4As1Tz2JWV6CDDdlZ8VHbsjYViW5clJt8VWU2e+/zn1HyYyBbwX0aO2L4JVyLvRsQ/2uJCquRFQndHsSyFo6VkXbvXJGys6PzPTZ6O1WYkQUks4IAujynQe3lgD2Z5nnzIzYRoRrl9AuNFlk6uWELQfS8uB/hcsOAbcQVApGUimzBbX9Ha9lGsJ24+ZUZkgyUU5Xq3hAGjordpmOf3x/X+YJP+A6i/VPzsmJcWGd9PgTtznY7fods6NGOTlKbwj9Wf4z9qBzjQhFU0KWS5tijW/pxTEOVoFOX4I5MzYNLzUEztOgxARR7YJVa418kmeC/wRPpfitKdzOduQavxzzgzGjZvf70hFn82rBhRq31NEb3NetIY1RsAhNo3Gz9RM0d1g59YWMpnjjAGOanvSOruXpDPNJQOnurR5xbvnBtQDflPAAmpuhQ+IyWR0e8ESNhy/g69YOnCqbeZNZd2oMGMyQxkRzyEH8T2qgxCPpwZ0JoUNTHs4ytRrEytdyUzYzu2ngUUrXv6VlGgh6lXO+cGFH3OLbZA94Qil1ZFvvppHEewDXLLkdeuKEezFZpNx3ch/2fnu+/aRtIVaS9T2tHiw8OC2D/wpNXDjA7PARSticglAFq6k3rmm1VIt3L2378ae9gXYWE1umCNWJ6Zevwh7KKcMtZ1srYbNxP1KB8TsQva83PkMXXLgM8CwOcFYjWSjZbFSbEWKvJhNo3EjmIYkdrnyzINe5zCpnbgI7V/98XNDAhAA2GbcJJAKaiys0xcf8n7sUAz4+gzsdT5Xq/vUityVkWv7w05DjieP7zNZw5gswZ1RMZXzKRCemDzLcnvbX3FcgTkWgo28UVCFZykfnh6fAgwZWqzKE6XeuxEmrkCZ9hoMah2XM3MR8CwVL2yCpxWq4ZsbhEaX3tmZvcvmFFQElVzMxMPj3Rq+W3VJBVHACUDCnf7u4RiX+2sNCiIo203bLsObN1FlCPG7wQl7Ho6Xl2Ci8vfJfGyysrNs3dTLh6vrdv3+55hQ4722klWBC8PySZgh7i65hz7wZ3aeGn1eGcFswyhOYLrYhC7YzdtPtuM6eku8Riv5tu8dng982mj5hPdaIiqSAfwFVBgncbEZnyJXzr3D5+KDfJ/keZCbrcEYch4B0BaZlTlt4JEhplYp+TD72ZL4jT52KHRUdgfDCUf1Rlzjdmp7qxqN/A9jt3GbvuvUX0oO6iY95RF3hm93XxnZq/N0sOk//WZwoPFlagyA5AVSUDlbc+ZtX5UsgD3KeTtC3CvhlktkkLJXH/HtnWQMq2XGhfBQ+VBhhCYc98G3L03GZfRppOBuUXmtl4ZQTieEStlPAy2/AWn8BWr+Qzev7oHH/z0WjM4jG/vVbOcpuSxckoFWYhdyz+Hbd6GNOrZcEhF+bUvQFHa107A4x3bEoDb3ywDw+3a83O64WR7HDyrCD2wFbizIfeaDcoKif7P4zTgIOVynL9AL7CA3FfNwCLO2xt9KkrLnuo9A8qOcEf4O53T8RczMuF+eqV2tZpTm8tLdBtT6u0ysno9RgGzunVf5Bn+TcW66EB8qArmRA/grAk+UqVnV7/Jrxht8pxq47QC7TblAGQPp6IHNK++ETPXh+W0zSPVpmV7/eejQr2g5/SOVY4qQ2SXRRgngD3dsbPE9ucSktqne6lrTVAapHVM/zgAvep0H38ryonbKQOKodJkmp+zTxk7ceYZ8iW5j5OLb2ds/sUFtcKRen/uMK5kXvoAhcA/ohLQ+37cqkFsrVDh5pDzSKliHHjUEhETEP7mBYvsKSW2j8yOQLBmUer1R1zfVNHeo3RYiNN5LzYa7KtmUM5+ZJPW5zvXTz5x44xZ84b/eDqdB0STnxWkRiP1kwbfoUmdyb4AYN+zmnHWwoNNaXASxcbMBHBvPWAo/5nUBTZ1ZUzixTGwNSFFdT2fK3SUUt12xmsa8cDklMyEp+W+iI7VMgkwJ2NmqTNGVIPXxdDEuNTXP0Axm153EVI+Fwrq2EmFzhmEvGgthWOWL30Idqne2U5USaZXAbwp1t28UxYUSuC8kfDULdRGooMmy4aUZQ046UX8V5Svx+MCkg6ir9/Cs0AsEURFBblt7ZIY7bAVgP/NikOmQHcLQ3UizaFw84lY6mwCsqGQw6rhVIeKxnHburoulX1zlHv7NcPH9062M6SzhKOuMSOvVe0LzndpN1u3dYhBsSWxwIYFkf6EPxuBt+sRzsCIaD0K1xYddjrmDcc1/srAHFmaGI08YTEVBKgNWEXRABLNbjmv0AM1nliw5KLw6XClRlckit3oagmV9jZputI4MWazqktSPCQkybMBRnOHQhSASlu0B4hAsLaabNuHXg==", - "managerBlob": "VSkpxRvuJ1lKyV71UsHuewPq731N6C5LonOCqSHGAi92tthyiwysOXTRd9qy/Y5zxkqcBMp9cAJIxmbIe2E040LLpkEiita/ISl22W1S92qzmR/7V4rHg4RYrB0L4GQfDRz1VHXvfz08ThHzNBauuxKw2y31l88dbDZhFoPWpnW06SPREO3EELWMZ7YniFTmvVXBU3U4YqFA4KIXVEeHSdGBRwPRH7SBr591cK+zQ7tF+3sUHuUV93uGotn7/YeIVl/FyPURU5NszJJEEkUalZ2vpwiqgzGWKjehmf8pvDRmNf9BjZ080RLVp9ivvPLzP+gs7ykLQ3g1rEwDF845S4o+MQZOYXUAb8XifJfZQv2eqPUu+Uf/qxdvu7bz0AAgPQwIobvcGAeopX1LwXD7MdnEtgwkgMTwBxpKGm4WXdhdAMk3Zc3UAkNDF6xOs28/eMIPElURAhHTG+dtDlESLKbwGY6oJ0sF4k9/bMqnGSEAYWn/OuXpBO0xYKIfBoQPxPT63AOIwMvj8lmzcEYC6Ic2/hFVgzN5Jt5KYEtL51jqFnWjqAQaAGtdiSJqYqQIHbogAzIVldSxX4HYrgg7hi46JoCV6onJBPKi+kXPvf2gN7WOX+kLkwmn7PNt5MLKTZL4/48Zz1zz66L3NhCbuCyKgNAi33PlvXZ9Yy7E8kH+e/XoLtM/B7IQkZIgu0RqJnzpZUhEJA2splpGWlLCy6S73XPLv4hB9pg8CjJI+f16HNAkdjMm415y548HY7tz5B1pW6anqLJY+f5sui/7+eGNDNIkFzBlZ7nEvckPwvZIBgRZTbcHYamX3I2W0JBFz7i/Kcl6btbuqx7C5bAooHVsiIuLGZXwAXnfQxBiLy88tmCcu6CVpadjzdm04JB93CLr6hOA48xGgjwCKrf1X2/i0X7MUvSTjcVqvmfCwb2wDTp0PF7fxdEee99aUmDwlyLdeif8uPxuMjsfM0k1PkaJ0eqpus1YJgBAsUOc4PcgB/M3ykem8blF119yRduwNyt+ABSFp8/oTl93ViRAdqWav5Zj5DGMqrmrX6ZSrzXmddrrEo3b63Uuf5tetATheOzDJzM7bjCXyDkjNt+DGfp3x0Bu2/wmqcmPocHU1uf4S33gihlMP0WfqTKh2wAaUjrsQItpS/9YRp3F8aa2U0Vlso6XiM6vxWigk9Y+W+tDE/Rccdlky0Lj1dC96ZIrnNleN6/Eov9U1Ro4pz/w0khFH8OYWJZDHhDa/5MOcR3SxfQDJuGqUjtvOo+0L+67SQAc2J8SILkQyX+LK+6/IszTaYeytvNrXhiZg15vLYm3VRm9AiOs+KGSfR6RRrbClAbUKd5eio/pbv3JPU08CQ8wNEonKQjMyOz2lBiHcer8RcTrqvrOQPlQJdMkI1ONPlu3hobXftX5tGRs3muJBYOVwKhV/mf5e7rZ0TE9JR8rcaXVyAvzdvkfNvoW56Qrh/q1lAEB2Jh/ZMgtwP0cm9bYjrXARO4dsgkM/7tGJ3efr0CwJ+yp0FgaHzprv0+nw6Npr8FL9mzff38LglTFdLTshngvJ2htUQS3xxc64HfCMBBsXoYzOe+F+HJ+8RKTuNMUWSV3NSrr/4tdo6oZjcnmd4KzdDJ35GubY6X2Q3MGg8855/S60NUP2fUajmS5LrQHG0lDfo+blktEeWvzRmJuS6oRctc5Eiu0oSfxcEPD16RM+2oJqqlON/KGK9d2I3b+mXi6Siigp/0=", - "commentsBlob": "dbHfSkR+7wXmgeSuDk3gya1IVdsVGCwAtr9yzZ+UTwsaV3+pSJyhRL8YAEEj40bFMg7jSK4Jl6Bv467u0gFah0/wLL/No9JGTY6zDQs8vtYIEcncima5j9lPan6bu5BKUW/DZnYULMX0VGSG7BUnxgUx3RimCogMbSZ65r2j2G3s73WHCNNpHkkkWku7WATVeJUY4wSLOPQDgx3pQ89oxFMQv1U+AFxLSz6VyZDe5ftPlKXZs/ziJVSjEaTW9QJ4Pg66QlhLkkQwhXHmim58wAjaOynKmc++skOwipotsyWJgAJm5Ue4ff3oJwsyBKHOBWBY+sBfdSmVqywZ0fTGEyZrJyrwqRkjPdiXEHEM2OFGm1HAtT0=", + "employeeSealedKey": "R9BtaATlIjeb0CKQIXT5SauOfF+FKBkoEQolXOcCpRpmUgnyH7pyL/XucxMqZs6lEx8ulf4ENAY+f6oZF8OYVcf77UPJVPLAF6pLgMp6SQ0=", + "managerSealedKey": "0LNdV8GI3mVEkwmyyMozT0jR6u8G4yb54Ttb7YPGUmrpm4qRULUOdxqvRp2SGvpvrLk0i6UURdO5aEQZZbdQDNQAPQRWGe4U16+EZCoAUk0=", + "employeeBlob": "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", + "managerBlob": "+p7CKMWhpsI59cjQ2Eq4wzyUrYv/OEGGoNKLzfDL3WjarvrB0j8UhozRR4LId4D6bozAX4gvEGKjyLDbbGDHDELKwwTIiV0IB1rJpzpUUxkvRqHk1oJbFtSVKESRxCded/DCgrjLYT7mLK4zXZ4HKXjjDQ70C/kUuvitEafmbxjDFgP2mK8ThHdSCDl5fdJMkGWn+Mj7XaNIBBIRZeXclux8mSo3lu2gkUpE/+yDMG7JGH4mlg4cyeC9JFluEoyHQi/WbhPcp7wX202WWt20Iib50UOaqJmJt8IHSGVmCeTKxGI9j+H9gYTaYZqNBZ8zQT6fhlh1Yg8Qv/apKyTkyKU8iAKajsh87k4NVHIyIvG5vzQU5+iqDcFiCDU/LbVkrAjkcj/RckPG/oy/Nen3hmCeGBrQD/8O+HoKOve/j3Hb1yamFJsFM0H20ahtVqpzEEdFUYOr9i8MZ9GtXWSdJfvyLsSKTOK/yssCMHGXieoU0M0DSIfKolQGA2eEmhw6F6B6gmB+HFTW32UkwXf3GO9/07TJrB8V7AE3jw4wVYmqD3HE+b+yO6HU1JH89W2ktWiqll7WJUB3ovAJJ3bUoaWpprbqHAODLipwlKMhqJE5KLHEW/RAF9uCtf3eKaqMyGopvN43v4mxAYWrjTmmlokuAeafTEX9TVdmcYru7YtdTVHFqKf3KqDq0G4mNbMBXfHQz2RINfyX6enx4I7l74rJ3yIVJlYQsRRstOuZs99aTPoejGDUBjMRxAX1Pg8/wHbRVQE0qjf8xEOU/0o+9KObQwfzQg2RAirMbKhfn4LKUgaNxeVEx7qWKVfmw7dwd0EXmM+l/cI2icAAhQMbmFgcCnq0hljo2852rVoQf+9twKRJ+NrarGibXEulq/bXrGnX81rWSppVG2TCKK6gMW0kpiVIoAABwezWlMtKEoRctVNQmxhyLOgy3DG2WPDdoJE08g56PuilwgtzQnzn8vlBj77XLkvohDlqzfefAWr1i6K34h2YpUqLLIzHZ4wPKIJTf1pj92isk4RMV6sr8UUXC0/l1pPfvO9xVV3yEHJ2/C0C3zHwRCSklhll08oz9jxkUzGe5grV6QlOxM6JGr0Oye9PgZHQqykgvkGA9V01yTIhBUcUN0WEX7F3WA+bH9LvQuJZSr0KVOpgjjfCCUMx6EEKpa9RIQDlIZj5isroGDHr1aqrUguO5YPWADTpCmnik5os+8wQuCFM83DipPOZBjQ0hJypDmZIqRtctEDwXVx36gKn0bD7dF/dZvxmPHSpySE5n1LVC9bI+IZ66i1ddOEzCjnydwQemhfhy65EbjIGS+YSkjt1y4SovsS/jhsbb1amX8SIsaPBPPKZavVeSmdnawjOfGo9c3QEjKnCS4dXNZ+txmM/06m5oeBPPk3vk0vWICBL6V+a43joSSPpo02jyaI5vCUOkIOp4g==", + "commentsBlob": "xlau6JRov8uO7/oqxlbw8Mvla7dFaj+svXqCSPRLCAiTe/Gszg/O2JkGRU5slX116QFhnmhS/6ioaP6jQxIxj/iWZV7QQiaw9SerZANKrk/L0vfLYFlZZu8zONDbix9ZiZshZi/YHnho2fxV7E+LJfEK5bTo5E6q0qVDJxPxjjt/q+lTxj2jWfQX5E7rxd68//GlX4pdyTi9qfw/rf1uD1vwig8woPFGqbvEKq+cA6G6tXk+8FS7CByZaX8brE6IkBaT4cUE+ljYV6fOLTLYv99jYDxpY0Uhbptd5cjedaLjAY5sznI/EIorGHOy1jm0dtt5CldqQ+JHoNVn3HWUGvrUnV3BbWs1IXKPPhRkmG+x9lKXUSQ=", "commentsVersion": 1, - "outcomesBlob": "P7Gu8/5sPGgBP064abNQ12XG9zlmUzPvqG/gwu1pZeeAmXCqwX6FT7ypnDGbZEiOsnBJP+XwAuAjmKG2d24GwIzChGEShrAaQWkPgWOKQ1OZXiKulZ6XmcAEJKdJ5aXEvFouyOFJ6Bb6G4j3qneMZ6UnsJN3+14qA31aCPjNvDetnJdDaXJpuA/oj+nBSjkaLyOhWgjRA/GnMSGVZTnmZUye/pVHqn2wazyScZJDQjVWxi5D6+a/EDLjhnzBWpVkpp3ioY9ApTaHADyN8fJ/soaEbLeYr+qPZKmdnl+GYDvy9m4aN+HbJnlwc0+0eeqlucw5fCtqvH9ChC+5P8y9mFDgmFisC7oZSHGi4Sb9yYOXNYrL7fz6rrAoDE+/FCKvsq9cfkpJPgQu95oaEyTgm6VXzvL8mabF8td9EIrosMq3NI32CA6CPNj75Meh1WLkgS428lfQM+BzlOMQJIKAocshaezRB5chZvdtmv2FqfDdMZVmhkPxC380Z8HuPWVmEpi64koxZ5Q60Os88zBCqF6pqXPNENN6vXTg0+XTKK584JQZ9qv6OTzmd5WmbYoKpdiwEINCAWaNsjYLhbrsR0a/YT2HZ0aOBXQQUooL/Pumedmt4Pex+GGeGX7nNp9EIwtoiALX7zi9UtMEwnK72wV2XtJ7NiIfwJlJTXQ1RN8GRdLSR/ol+ZUGldlFuW+VmJWDz8YCh8gUHlnluhFKX1YeI9/nfBzXE7BZOVHzasWn5BuqM9hAUtyykSriOw==", + "outcomesBlob": "07FxrBf4J3XnK34eg4I8weAK6Lv5bPDsnpamRRCr/PWchjOQLw3m3eY/EfHyCtchIOrqw+DJLf4kRsecytyaYorpeZxQ8DPnmZ2AgsT8mW9+1A9I3DL+sVATVu75E4wYKk9HgAzMmYhdQHYf4y9ynDeWCLUFnAP7xCB4MaCNtHIpEAFg9B+wSxbU07n3sl6PoD9BQrp8RrtYkMG8nDdgPPx3NrALqL7qJwvIK9QI4jkRNh7nWHlRmVZJmaX0Lyk9m3/HtmbqMXP2eiiIxQFKOHW6aU8JUzpFOUcNaf7Ps9wjDCwjouBWdnmcctqvrp9lsALwmiuCLyRO+rLcpxw/DbQ0xJi7d6kktKOAWpHGap5WQZEFVaDnNjn2O4vq+m3in7zOukxNAZZUmZ8CusfDVZS9+riIQtw9NCVMEq2vgvJIv71/MuTeYyLJBLJ8KaGMW3RZGELXN+QPBWI0weDHjemq2u5FrzIsgfj4LQZyeIDuEI+HxlEmf5GjIpLYiC02r1UbPrVxwPMsJEy/sNbtRkdmgqsZNRKt8QG/8fIh4w+BXMfDDpSl4MW8mdxIL4e/Y//32PuKFRBQuj2ze80Sg4jx8M2QfPvHQkZil8OzOccovlbWCP5UH782+V3dbaptEPTadBViRd2UuqHJTX5dBzvIFqUy+sKRrUAMOVSLJ051vXOADQ8+MOm/OfDH2LTqMHqUe0C41BvJ/kNqjBVMKgEKHqDNeuLdoNXTOnGaZPGdpfKSbxd0TjU7gdgO2A==", "outcomesVersion": 2, - "goalCheckpointsBlob": "EpUoBX36TNCoG0oPacIUGkBIMkdOT7VwNoETFgoHzKIjgij9zMcHBNymzDfxaqZOLisaAls1GSKD2WiYEqDMKBkXy0qFF9z/mwF+bwmL4gF3lJd6nIMueg4OmWqeivMdHDz7KWgdPdGKlzkXvKglA6xyVS1Ke0e7ikiFhV3t2x+SxmZlJKN9Jf2g/5FUrcWYPqJQb11QYDq7x5QbvG+sbkQD1V758SaY/kXOQED2FT5WlhOOl8d4UBj9dzlUspglGHRzEDjXy6dwQp3ACLMmdYPODESDF/K4axE95vbnGWdz1sZbttne9/YcXX4sV1dFRlB3+iHGSZrR0YxbdoksmkMuVhFzR0+k4k8Ygo2AZulof0ciQ1nUbkWyD/dxrBHLLdh71Kvbjlu9F78QQGVPAnWQNxIpj0hATF4LxXECbLD/2JDinY6d6iSeWK4ft299vnfgqQ==", - "goalCheckpointsVersion": 1 + "goalCheckpointsBlob": "4BdBWmH350Ss9H7M2zO7g6w6iZ98YL9UKiEJgGIQp0hLsTmXa7UfU8aGlcpZtk0h9Q0YkIvjGcjkZLGjdbdPZ0RXlhoQYMVoXlGca4jVRe7C2klyvxMxzu6AdjJ7Mk2rfeK0Z7fGhlOCSMNgMRVF4yAI6Xmh4+T1t9hvFHhHhtDWaNGVdwp2+zwYZCJF8i/wJNdSb+CKfaQxB7e2IY8MnNahDRDLUH1xb/U/5Dn79RzDxjDZNCeoh2L2sVBu736MTSvWRH00GWRLhPJfeQ9B1uz4bA9ucsaliICvKrpIy4S3S/12eP7dvwc9ZmdWspdgtkZvmnwwJYbyzUSoShsWYrc8ghvj6As7C9dIlcT6shIGCJCbrQaQONOHBC5yDggWnFK8IGT1YqL6L0QgeJoYKG35ZMbRyk3vQ3boOnEwM69JD8myfCsP1PHFIDxEg+nNQZj7hw==", + "goalCheckpointsVersion": 1, + "topicsBlob": "icbjzxf4i3rvP2FDYxAXkLw10nng37mo6S8eYCFbmqPP5rznstouAdlVWI5eHIzFHOQvd1d8lsyVc2RxJ2vuAK9MqMsgtbNxZteJ2IrjOdG/O2L8e/SF/vZStCKem60njaC+4+Z1zh5mKy/gdbVI2xW5HCBGW+pZ+kk9vc4gpGSbIvgEyqC7U0lqiWePhKBwx1FVglryqke3/0DsKjOiT7xfKf9cEq+aD3embrEZCUDdNfhJHDylKacWRfxfi2Ue1G6TRfVcDNiVhosewOQhZs4CW8B+flRFErR8EUGcNueVwgVsko7pAWugjR5+GnMqUO1LOIh2VUvZy/DKHZAO5ivvjZ0VGafXr4zQIHWTVOHryeQV9HisD3guS9icfiWRNyNJ/8eoMe9ZxICTssuukcBtuo64Nnc8kGnl4VNDLFUeI5Ud5fwIv1oSwP7Zx1PLMtpB0B6sz+FRe24CMsiOlvoE5Q/2nHBKrWiBQ+IeiVmkmRfu17OrhQ+ymyabWHle4B1uke6irVGjFcYI/aKbXrNH6ap5B3zdmF5D67Xk6YytVF8ZfhkzCP+br/XDFgQG+ETqt2AfOHVxNXpibpA881K4nFnALNcG/6zI6smdIcUTA1OkBnP6JTLv4Q/AvMfdJvpdl+4fKizsulpcAkhd0pkjanyDb1lSI8G6DbnK3j21YZrYx0/yIIFMAo7w6plqn7I00mNwIAxyT+an/qp7Zd00t1JibxpV0RCc9YK4z8BtfbmgXOqHQhjdGVhXUVKwgE3cPzC+ZTVgycTeL57InjvY1u8UoQM7WAw=", + "topicsVersion": 3 }, { "archived": false, "missed": false, - "employeeSealedKey": "Eqb5ExooasyIENAS+ABjAKCchB5ddAX5cElhBmrH0Ur3izEDmAE3n8M3MlGVbFdkJS24MUdiFNJxmQoJh8pLteBxoqR6caPskSAIXsubd34=", - "managerSealedKey": "r/1PA4lJYL1HqNZgFiD6LI5DhPyR12QUaDradP1xgwZfsNLeEO5gIyox08ZjC+fyBlUe7Bf7yrMv0+RhRsk5r4BhF2Og+S6pC9Z2mmrwtMg=", + "employeeSealedKey": "Hltjf5GiQdsmSnwGBpzX8PF1jsZlhNR14pnqzsUVpXa0lUHGzIwKMjM0Eh+jGbgBRo/PheAlVHv3pNFFA3ysFVjH2y8Wnnyaz9GAGhVUDHg=", + "managerSealedKey": "YQW6paPLQPzo+on7frRQzF6zu7JceL+ucQQCGdvvbBsYtAW3eIhYurcRmwtUU6BH3G3P4HHK9AetK92/UTXlrjOyeLiWkppHRTY1cC341Ic=", "employeeBlob": null, "managerBlob": null, "commentsBlob": null, "commentsVersion": 0, - "outcomesBlob": "FwTBgy8KQF6kKXuLwBLYUpApBVStLJfm5hfMnkNeXFsu3LOYKwEzp01u6R21y3wPg5eHbMY3yH/ztfG2fI69wHxE2HUDyah7NkukTd2ufmamq3JpK5lpBMzGPZweR9c7i8oZ2fYMCXc7greb8WnyXR8cWEKDUgL7pjb4zadGc6nSjGwzGNpZHiMybof6eNtq8K/PqAG9wvYEni1eOXyHtS4bJ0rGkpWZZTdk55uH7tnN72kPeCh0Afh+eqWXtLUBZNUkO0fVY+sVjTgOv2jO9Ztb78rvtNDUdMv2cYoJehrHw+pjAi2onpL5yaPiCKeDdVXjX+Imi33tHGJ6QV4HnTrVsSM0rshy6GQWKVs6R1KfyqcZqK+iqmyEspXYkYU5NP/w+JgJpQmKSH/go66EMvRSSE+jYjd3pdSI2Gewzt4IZFNiUCGonNG4FWXf4w==", + "outcomesBlob": "qSghMmlSqgGbOJboX+9O/vLH5fVe1J3umcNJUy453jowrsBje+mwkhIc70itGKXP/QCVUqsKrC2ADSp+gQhP2nQRtnAy+PTTotA9hM6nwnV4F06iaYGfN7gQfV4l7MHDeC4pnLLD++QVHC4ZHUMQW8M8RTHgzwT77c6BgAUzxk542u/2vDDqflhT+OSpa6ByaKIDt82YS32fR73okjsA4MVYbS5F5UfmoKFT/Hh82m/1hZaMO3teEH9dj6O6o1LefqREARfXes0uGWprgOw1UlG3Hp8086eaRTcvy5bD7SkkCAeDyey57G66ImZ/eD5gDpznwP3b47rmO1HNFClW7bxF+gxT08+Er/XpnSfiN/kTl1DcjcoK3RtFLhjH9HdUTqfbc+Pg0j9SJD0eXqnLRZwinc8wSvaidyB/dbqbW6AbZ9z2lVz+gcEJUd1asQ==", "outcomesVersion": 0, "goalCheckpointsBlob": null, - "goalCheckpointsVersion": 0 + "goalCheckpointsVersion": 0, + "topicsBlob": "irCySAFw+d99pXPVm+huufDqPD9RiueqqY8hDpbHpkSupdvIvLrNuqH9hL8dGJSYV/KpbrIas+L7oPEa8NagSNhjt5llvjZpZrVXhB+2twyllOU4fCPncWjZzr32lPG3MKpDUIa4DROqu1TDkU2ZEXMYwExRHoBZy8DtEePcVs/YQB/acypdJfXZG0qzWYorGdOCBCF5Aw3ayy5bjxTxPEam+E8AQCuU5vy+QKXzSzMM4iCFGUoSpQBlj99/szGyutMwpTYBK/QqvT9ZOb6+q9N0sk11MLKRLOb6JJcGYjf9cVUCuseWcYZbrHw6NkfQzY7jFRsOgbzUuGtKSW/zHyBYnoAfc99hgmON23MdktuosjXnbAClIr4ZsdYtn2u5n1DYd0j3IihJYrBL6LkK/1k4TzXQDaCLPJxUL+9P1Xr1Dytjq9Iriz/EYuSmsIk=", + "topicsVersion": 0 } ] }, @@ -73,20 +79,20 @@ "employee": { "email": "demo-employee-ru@example.com", "name": "Анна Соколова", - "id": "01a0890f-424c-7ad3-a739-8c7785c38f3c", + "id": "01a10698-2ab2-77a8-9bb9-22ac346b5af5", "authHash": "7UNrx388/DPnlda5eumwoIqAhbjBpaZDYYWZAkihJkw=", - "publicKey": "uouTxla1GvHXFvreEGf7zJCgnvnEDPb7flTBZLJ481o=", - "encryptedPrivateKey": "04LBwbPOtN6wGQdJwCJF9ocbnZp/b6Cvu10IGyYB9GiW8Hj8ARfbk7DaQVer+SLBV/Ku1W/cUTWHLDI4xg6LU9+UhU5oB+fN" + "publicKey": "JMxwpVhJzbWu44MtaOXoj3SXoST3wh4L+eRnesR3hCI=", + "encryptedPrivateKey": "hFN9FZQnvJ0F/vyUOaEsHATSYBaovbrxTHa7fiCt9yjEh99zYWo9JscU3FjYiU62tXcQVUk4feHnEbxtCGvliFcdBDqlPnVB" }, "manager": { "email": "demo-manager-ru@example.com", "name": "Дмитрий Волков", - "id": "01a0890f-43e2-7ecf-a25f-262849520d4a", + "id": "01a10698-2ca0-76e3-a58e-b7a0067882c1", "authHash": "yABef+SPrkO6d1jot/NelYPDGMxlQyaWpJz2K0VGrlI=", - "publicKey": "/97sFQc1rdw4jcO4P4EhUPXtV/I5X8BVYhqd+/4Ph2s=", - "encryptedPrivateKey": "75G/I7/vje/w4edi2pptky442+j4qgQQKq+P1F5y29urzAtA9R7bJu41Xml1/UEN0s8BXEb0Byf+xM2c0UY+DVORxvTa1wnK" + "publicKey": "gbcCcvjAaWJnMIfScXUqbof1YT2ZWclKAR5doXv/1TA=", + "encryptedPrivateKey": "uOIyEObAucaNQNZXw/gCroXYGQvzYXJO3esgZRlD/LJ4IU+OMBmGbY5iCSDm4UT4bU/6Hxj36fJ3VFpb6igXiIqpr8xugFKS" }, - "goalUuid": "9c8c2e4e-c714-4929-a044-17d3fe14a69e", + "goalUuid": "6ec22329-fa7d-431a-ae33-c69cb8fdef41", "goalTitle": "Довести один межкомандный проект от начала до конца", "goalDescription": "Взять на себя постановку задач и реализацию проекта, охватывающего минимум две команды.", "goalTargetDateOffsetMonths": 4, @@ -95,44 +101,50 @@ { "archived": true, "missed": false, - "employeeSealedKey": "kbFqybavsvhDBX89eHzD8gYhzFOySiN5j2uFvHkmKWSppnP6Zxqh7v0VbXXEihKPnWSp2WXR/wpEsLwuV8aHHiXpRcnRy+Ony7dif1pSoMQ=", - "managerSealedKey": "tYxD1AH6BgfVXqBC6JGNmbGKzX+pGMhCh1p07mhPSQO5uBeH8yIzV8NW46ws8CdCs3NzUOuLB8VW9yW1FqrRod5fW296kxtglW93HIZLUJ8=", - "employeeBlob": "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", - "managerBlob": "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", - "commentsBlob": "CoCRQc4m31JYwEn0eKQWPldpPE1HPG7v0rcufW0TwRnLmNWf8R9hyVp2mTyYV6WE/9R2o4f5N8uUNa32bW33lECKv8t08EopkxoMHxEU1R9R7a2JNoENCGDnYRGhHzMHI+pvoiumH/2dQzF0d35fTon/uEwnyT+c5uVZWG97jcGgE3wFN1e0W1oC3HZUFn4B7DNnAXnDxH0w6hlQ4Vf92tP3iRSMaJg2ERF8lmbocPgdIgIB7N5fv/ARMnf5IfAXxUI0bBRHhI+jhhKPCzwXhi17ZoMxK/EzXRc3CAcUzXqXE+2woRoepjOOz0XOZA3o6oZ78e6VvgVz0wQ/nwYwkvxtUY7qaa3JNzi2R46enM/bI7beskAkT0XGSNGHw3I3dfqsMst0ckHANzdHOk13h7sLk/AWXNsp/GN18o4buIpRJEudUBXzHrhdiWBH0/8InRnANYfrycgi/TFpqJJkCdWZ3gQVcUGHC/mEW30zswxL/VoPO9f183DgC1ODyssRqzopnvylrZmUUhBQ19DoM20uGC0llN6yUrcjgudkEpglJWPy+ADm1RpZXuRw", + "employeeSealedKey": "oKWCXIOeEBNmXEwJy2m8fYMZYkm2BOlg2l6YLqUkbj3QV1MAAX0sPFAVN2XYV0C3k3VzjUFrKtISDGiywu2E1dzZp4o29hHCToMrW2pWXIU=", + "managerSealedKey": "hSNZk/hGz4vQEVKMkHIODCxzosq07XhDmcdYnWLOiAVXO922yTGka+zrWKY6w9E4q0M34pY9yJl9cwU2AXeX7mcKGeeXXZ+llu/xqcJUR+0=", + "employeeBlob": "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", + "managerBlob": "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", + "commentsBlob": "fZywupfCXU3nctpvxWw86IOQdSa9fHbRYXGbdR0F4n4fTHiS6N4paRykGrEWinGklYNpRnJXJ4y/n7XjoX2lptUwRlBl3sciNygrece4Rw34Mwr0tPUQtjJK5ixZBMWEnwhbwBqdmorD3ZLXRMKiINPaVLlrO8tZwSI5WQ3Q29ZWObhl2nzDZbDY/QutFyO131F/+UsEAJ6wwAnBugDClMvjWd8qSwujTS/F02v3sY1kEPptl//P9GjP+qGePFsXB+0hu5Rm4iAbmOJhABnSB65/YI21et1g679D6V6gEcHamn65ADJvtDoZx3BUKrKO+M4BbkPmZhZXzx8L6ZfvN13sx7nuFuw2hRHzmKLkGMvfeBOUPB+rKWrZeMGpUAXG160h0AsZUk976cTb13c/psz8DKtQbxerBM2YxkuB8QXl/auI7Xqvrf+qrty6U9aOFGIfktEf5AfbOusYUmeMSow13u7w5AAwIvyeBsUbbUgY+4G6WyKLPIWrA+JAvtR+F2xYH4nklVWJW/P3kSuJYTdJz4uTb8KNCnFYeVGsYm5L1JnCBe3dtDEhj858", "commentsVersion": 1, - "outcomesBlob": "Bt+J/gl0f2zFKEZxMCBMKJrklOoGfzZbmn7/9wptUXv95z3g3UgUpN/K0pSTUmNu4b9LExExP0ELv08whVMvAeXgS+6Vu1IbANir2bZQ1dijky5bCCnHEy85ERHNKK1Ot3IVhbR1SvB+lR3lN2z8ix4jd40kKnjHeTYAfCgOC7ZuIwuhCYfC0hApoWo69iKnNtMgtqZubsKnKc2LVgHadsD7xW9qJzqJcxV7IAy1tc6AVtnIz8q158j5dRk7VcZWe+atqVTW6M5E4ib1mdgwdtJufs3KaIFSDD+HQrLmffuXTa1qpr5cebLN/idEYGXdS9hoKZQBRtx5yyEN43YDj1Uc5yXsY53kXy4KznKSQNeXQlKRbiJJyY8y1RX4OO9/bYqpbKI41cKgwd4n67W194CLnoY+oGgt9GjEsyWTLTqdM5J1gkDc2xGCiS/bf/sDAgIFOdrWKb3ninAJF+YMmvt8Cu6IVxv+5eEaVva2FluEd7UnbR8C7P4C4Qgy32zl/Q6tQvzezZWOUkL+vA==", + "outcomesBlob": "jT3/ZPbZrDD7l1mXpbZNz5527WScldF2Scg+RLH2eKdjLprY8DXfw3thIF8XXWPZtuIkMG5KuaPg6qlCivKHBbN6J4wKasxJ/WxUhOe65z5GdkE5Ec4Ym46RLtjQkNUDqUgNvmCPQ8wSbufn6v2AfEt1ETOV7NFjkEM7X8Jos9k4fg7tdYBOihXvboMVPwdLUsz23XJSpwHCsqYRtBw4bU7NJw+aVQ7Sx9eXoaLV0nCDapdSDwfhK0rXnAehPaXEd7Q/vrDn05gL5ZOWgRSyNIK4DkHsGXZV+Gu+GPmO4F+HOhatR5cgYpE0n7iyhLYfelpCi7ZkUF5tAhzupJ1/rykI4oekpRfC22VAbjt3apHzb4CP5AZB/0Un3e/qLylW73HgyJprOrk7HggjNFmqtIGZuuCQyhY1ydxAEVcL7eQRlXWeEmc10ny25lIo3X+PnZZqDT5xwOxUh70JF7MMkQo1auBHg6P0lg+dNMDQJKTAwRuxeCQpTV+Y2iNuAcgltX/8MyHigNqm4efDVg==", "outcomesVersion": 1, - "goalCheckpointsBlob": "kN+8nUDo0yIluOyroDJfGafBS1BfCAIaCR5n0of7Y1dzCEzKSZvXh3jojeO06AE8qAjMkOBZxlNFHyvV7yu3XR7MHJlMgXxy4NnQMG3KWPZ1Ogg0tqXUxkWXLg7tAL4QCCGD098Xa3ALHal0XaAiOXtzEOjvrrr+F/7BDHaDme5h0gBDWlwwXtEhVUCPaM7s/2XVTXOuXid0kydymrpWnhUr4Es+HNZppj5Q+pciIZMBnWIutmHw2ZxUIBGp7iS5MrXshQTA9uyCJR5PvIlLujvKuxvgqzr6a6oy6NyI1BsZdjhb6+PuMmImbYuddpx9lcFggU4CuN7Ft6tJUNTrX5sliSjhRExC5MPozopxoE8l1oNCgw0Y2FbBJY7OH3x5teYJC/xJUailHxu/QE7+ymAxUUBb2ZtTEcUS2w/p5VnMgq+ese1n+sgp5bPmXXFe3u5RxaUrZXX8vnZEuU8b9Omh6WUlmWJZLMN9FpQJBOeCIzM8s7bUo/PXnnjiUN3Sblk+qqRB3aCzGjHRq0jj4C1zV5VCfMwXG4jbTCdNkmjs/bDO7EMviQTxzoHy", - "goalCheckpointsVersion": 1 + "goalCheckpointsBlob": "lCZ03UlAkgX5YLj0BU2vi9tEn8xWNulBWZ4FoMztIogt5BzgRgxIFGxml4m+MtmySslsxT2UZFGPJlRVP33DSkyf5ddIMGdU1HhnUuTe1RKK8v/rgrWkDoqPKsvNwT+w5bZt7Gl7OQLj7VmJDSt4XMmhMZdVcUxcAwdasfcdAD0oKVtD72sWqwA80af4FDrlWyViDdobMDUcw/www7vTy6itm2lPHlunQeHz9BQ9cyjnD5jZNcO34dpsF5/28yZocs8yI8h4EzQ9vT0qlc+FxXQdQ0JXYaL4GEe70ee+zDQfQapDD5mNuijwIKEXeFnEVp75E8BTycsdK76Tmd4eqQ+c1I3kltfTUSdnycKP8GeqYYHTL4MsuVadQ5XfnDMKPorBo4yCaNjxSHDaOvHBNjV3S2ZxwPL9Ku51njO42au/Xc8uoCU3UbddmOCNjoqJMo4Sk71lU+RwycTBAdpis1tVlB4XAqfBkp1ziNfl0Aatkic0B5sQHWnDidGSGV1u5OuQ6D4Cm+p3wRkN6HGfg+d0dU4xDLRFsb+8F+TJrR4l2AA62tfexrQKOeW4", + "goalCheckpointsVersion": 1, + "topicsBlob": "om2jQP7j4IOR/9Vnzeki7ryvZw4+K2vPG/MhT6duNSKvvqP7r32h6EoE6JJFdL0pNOEzRe0IxrPzbkSfvtDLV2f+YLKstbj7IeFvWp7Dz6yAbBAwcG4sRfxLOC0PvfQ2Z867gvmTE80Suimh43D5cWEamlaYXmMTiUw4ubhcp6Rk3d1PQecpP3Hoe42/1B/iRpTH2dRq3DVHSCvUQBk69BcL//Jf2/u3yV7ix7Gn4JYIbFoIhruowrbGoG0iOX7m7V+JKT3VaH118qoQPLH3/Jo6GOzRPRk4/6YtkRarStpLVO3AyRNvCaw7bDaOeDf4qns2cusme33za+IVU4b15lnTxcox569oxTtGYGw9LsWMl+HaQcMhbFA73A1UR1Wgv2bR/6ShTiQpiR/j+gFHFnyBtt8LxBU7HKYRwP3jBHWqsiUnzQB3VTL3V21twLKOX1GrZwvQsJxUW8gI1HiGvKI+xb99+eVeoCyOxOt3vGVfplSF/Bd/CCNqGFZ/tr/B+YYugxnKRCTGjvrBDkig7+GrcU1lTagUuDLoqgNLrIAFQ194z7KPEyAKvIWh1IXXciU2nbfs42/8akhkq6ou0nyfsVfvskYhJ6yK+c9QaoZrXZbYVRhFrzoFVMm3MEFsQo2tIZydaaswWOFvltABtFcI9kp3QOAhAHrxU3MqvGTsn7OA5/ufdcno1EMC9g16EONbIOk2t4sU9BgcDT2C97nR9nEmjg/mqUOHeC1RDj6/ybWAb/72sclu6C53ZAFtCIfVJkWez9+nD6IK+prhMGuFQHgGbmihbwM2DkjbYss9JLr+pq9ACM7UNhr7yCeRPaLCoA4GPWLfQX8ZZE8uhX9ZSnqI6tR4BK+sAGc7SMZIid7ptnbwDJz3T0Zcan6ccYX3+J6LREBer2ywOc1gCRR7frblT6oA5u1HbIxikCZf86llX13Zh5jAQh5VIynh9KNL5nm+CJjVc1gBsY8XXco0NvRWrDTc", + "topicsVersion": 4 }, { "archived": true, "missed": false, - "employeeSealedKey": "w5sYMCX/KnnANkOa6Yc6BxmWt/REingPQ1E+Gw7aYHOQYYXaOEWbq3TrRBLDSsVM8XpIndWOOF8Ll8wL6DiuG91OBDT7YD5o5wHu4hoVDKQ=", - "managerSealedKey": "6kevQVk0davcGIYHz9aNyQhd+SP4ZemcvYOWYDdknnM1IhTGi5KqsSxNcYoda4mPPNDy5LWEZZIbQRaZMj3fHSdLGJu+1C+aGkMh191cHXE=", - "employeeBlob": "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", - "managerBlob": "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", - "commentsBlob": "+x8IqyewqrHGPnN+2wiiC7c1DdkITyTGqDfJW+uzJXmtnlN/ndQSK0cPi0E4s/Scly/mRyCtuPqIc7siW8GxHUf0XKHAm9zdtwvY5ED+YCQyU3Fc09ebMFwpYJ04WcjL56YVPXEpfsPNjjz5Q62hntqCCil5NcXP+5DnOHsUenvLedDwKXSXEe1WnfzBrujLlPC7WvgBBgI5kiuzdOHN9u6e8gasZ6ataIkxB2wFNVlA5/O0JGloK56zZANJvLp7w8Qe/mztDCjwSokV0PnOFtNjsY/2rfiZ2cA6GIIi5otNKA6Xdawx3KBMYOyYRh2CPh7DJpCEdUBhKopXlAz3QK+MaG/XQAH4c/8MReKkZ3UMTjA+0+nmVoROWvfOvVJL1NLvLjFW0IJY1EFUg0VOuqZwoE6ronFHYPFr50Sxu0iTWbE=", + "employeeSealedKey": "Mf7/BO6N32oaYofihMryBWwDRHEhM3O36/+1gATTky8TvI7ZaSdxYyXwu7sv1RH1Wa4tuRQjkrV1E9ceeEn2/JFVCPJZZ8GfmrQJ+PuQr6w=", + "managerSealedKey": "Av3id3qzG4V/a1U6VtyibSz8PJYewriAK9U88K9tpno5fku9SWyw1XA48kMotL+UXZTZ4wwzjkWO8ugHuiL/0BOnahzTZllu93+uVQk5qxM=", + "employeeBlob": "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", + "managerBlob": "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", + "commentsBlob": "i6O9DnaKUl7p5C+MmFV+/4RvtzXYswdrucOUrKrVK5GLaVDzV6Cj+p2REsUJNFyHzi6hxdcGdx16fgDGM5AEKEAc0qOn38zQFZyZpuu5yjcm7g5hTB7+lo+UfCxFXTeU596FOYiCEoo7K99XBqip9Ky9ZULHgyndlH/aeyDWTx6+4vlvvxVC71KtOHiH10Nu0LonZQXut2Kc6RPL77AkzZ+ziaQk0HzWnCMta0uo1K3atvgAWvF4rDCdWWLz+shc7M1V0aPRyD0p5VSyMCLsJdo79YD11WKzM1uzatS9Squu8+UkMuOz9NmNi75rTBS4DY/q4hkzDSPRyuvlp3Kr7jDjjvWMp4qrqjEBkol+G35Abr4TdG7TUVQQr9NSbBebRqGr+aiBJwI+X1fmFB9UJSs6Lre/Wy8jdTCEQb2CILwE4fE=", "commentsVersion": 1, - "outcomesBlob": "9//skWcwI/PGZEZX4MUC86XaCaKWvlZl7C5jZ/D2XmRIaIfA/8tyKSFR/S9jEn1jXbkErE/Pi2FdIPZJpDx9VblWIfm7LQv0Pnf7MQNjugZSb7pGRHv82DS3q0kAscCXmzAwy0w4u6CG/RL2rH2l/rJe8nQEXaja+tr4k4/xYf1juXK2UG3IrEPyqaaO+YitQiRhwXY2fYbOnW6GXYPdzs+b+vdyLoDJqv9+C6KxNQInBvrgpDIj+S+sOqUXreAsTEsW5JwpxmroyTcmXl1U8qfvxBfjw4YMfi3sBANdPkcUBCavxNkkU9/Q+oYmGxBdNtWDFcGHyN8ouXFjyvpdPEjDuUEXt7E0TmWOKA6O3IDjYIeUkvGDx1uybNALbyI39lKMJRD9OD7gkgm56OddQH9cp9iWQNTajjs3iyOiyLw9+AncfxxUENrez/HipLPxGily+k7Wy1kP9DjcwYd1G0mjinFq9hAfTkgt714J5nX9UPJBt0DfCiwL/NmEeNZNKo7JlvnyG3TmTEC3HfR+e4LUyeZO2phQY0TDONTU7rDDu39xQwxRgv4YQDLBX7Q2F3JITiHrsZs+aY6ysKvUi4c76oro16ENarhfeQJ6ICgBRw47BfgH/nryMHlyMjghzxDa/dYtvmjxcZjywysLg+2R7ah+lqTPMsQzW6OVb/YguXQLI6xDcQ3GFjEBhoR0sImtRj+G0BmtUol2VK1QqfacYkoe7F5NKPkcN4ClXDmYQ5yRyvxozLVChAS+Hef76gculIr2E0dkx2MSdANGjL4y+lT+gJ6ImnulT8DVLf7f5yvJlALxftHRexoKa8DhW20OWazwrkILYuDai1zju4PCmiS9gXqImhL+axa99Sqyw0U2D4otvNCEv2drFZqpYIxXbs2RHbo0rz0MOjYjOMRtSl+sfH/6WS99DD3dPKXJweUKNYTs9tO7tHshCaYLm28IIG8a6rFwgiVWs0J/v1KD89GDWVWmsBN/HrYr+qYU1KUUAyCEPyzB5gliHbJgaJhNyAL1BZAH", + "outcomesBlob": "lOZJWPW9YRAOYB6jqPxP2Ny/9qnnMJ0cAGScYoHifKu8eKqqwQl7dnPR6tc9Ksoj54/UwfzHHf2J5yvXv6hVQrsHEjbZ8I5WnlIIz3y6eOGgGX+999or+UrlWbZTs3gTRKXhwUif7GFB6Ldf1j9ZOtJcVJcIOsvLGaFQFYNsEcDe5vyaKc/EMTeSJIQGGsLaGcoZnmaxccN59g9bpe1dh+81/1QgmQbpwfCy0rptI/WIfVqefY73N6TsLF/idt6YyMmUzR3SmPlpWVzFU7AUb2aicyIuVybllbitDyu39Wbj0g5SMhvLvHLxKzVThvjuCDO2VKAtKJ74rXi0WfDkQ1TD91cf1NsKeWyH3zVp7L/Tdfp5L7Hrkj2Heeteyvyw482YRCOUiXF1+LMZV98DT7G/nSpJOGiRKQIPd1M3mksIZe2It9zP3/C9NY6fbybOGwfC2/rP+K0vUnMFpEaZNg5hV2tYN3y4DP9s2dt3GUS9RVVn5Mk8Yi4WYf9gOce0eQQdCjbheTx/5w+jMw62WHOa0m0hiy6UfSeCz1azGTl0fsvGVWdiLusHQet1VRl6gcQ01UpnchlECmMzfe2x2NmfdeRA6PZRcDc1iiH9FkGf1jp53aEP1B+BZliEQjT9ZtT8tmr7hosS+HFH5pRnXKZtIs9lz7B+c9IsIA9wR8BqFyOD+YyVr1j/vUedq28sWVQiAX+Vcd38JCBmk/7wTtrwxt8oPuQC+yVXVZl1XNhZrtxEFbqV9EGw4ExdZHZeoEKmwU65Jit5prJ0j3cHuxgFp99DGy1+OJ6OvT3DFRbzRJLZQKbtGw04ARHIrWUjAw3D0fpMk/Mw2KzWzRYTHDY+pKvbQwLWL9hCbXxVbfIlUyqRxiHXDxxwkCg1LDmyl0WSgOVtoP5S2YF3u9CrXiQ0tiRgXjxmNSJnTHMuPLLRuE2x23eypKtW6Q8IXolLB6Vp73q76LIf1KSZxtAd2/2lEly+Gr4YPEgN5Ykz20QkBwTjnjfzjoUK1tgnsYoeW9dnliHB0T1c", "outcomesVersion": 2, - "goalCheckpointsBlob": "Gkz247FF0TZ0ADF8mexhHX0wa4mEaBbKD/nWqWCS8VfbOJrz0jRBw2FGa1U5KEK/uCk7tA3FT1W1o7iOwr4Dc5Lp6QR2BrJWRuLKgoJBjEzg89mT+VIEg5XJORh4b/dT61oClyGz5MW++DfqM+bWEKZavvHZM7oP1X/wOmtIGl4i8g3I2vYSsJBIo+q7MKDS024pZEoq/Z+Bqqx9j3PSLnnC8CtPGaGFoOM8k6OIZ40eWlNrJZ03Sa11bp6Syx+D3Z1YvJpEvIOQgORbwYePwWNshVdKFjMVFEj2QKBpG4QOYvJOiQCR3msZcaTJ2za8bNEUk8puUerF4cKiGOEmVn9pW9bTaoWmwQtvjqyAy8xRGTRJeMx+ajqV/IuXt2JV9+lK/mYNoF27H5brgkqZErUWOXuRKPNSdz6SQwQE68cL08u6d2n37VBiqtNF0fQRvrqtoIDlka50gxTm6k2DOH0frP7j", - "goalCheckpointsVersion": 1 + "goalCheckpointsBlob": "SDgObuCXf+Tx3Q+oYMaKac/iVSXBidncuh3idqTK9cEbMiKbb4rGw3/464pNAlgMrubhumXUwJpajJ/gnd9yGN7c6wTyr+Pa+cOPCtcGffoS80DwPzdXxQPrREhwHqN2IcgN8PTthB4PldYtsnm5y6MYVej6fMDVTPQ74HUfs+3+WA9RxO8+gCP67UA1yKGYIeE0kLjrolQfk3kfv5GFBUa5X3zGDwWfMnP/iNlxAncqQ5kLY9IQgq6Yxeo4ZKuDJj4RFdBqnhlsRZyCCR2ZwPXvWCk47Fg4qD07xIA8f64t4QwveANFGiP3cJ9FwBVU2Y53OxwxVwmgviHesrMPPq3DV5WnmfFGVEVe2P2ahHiBdSyjelO9F3gtikgluCKeamAq4OoTWqGdhgGB4LicT88uUZEAOt9hZ/7VT1tL5t/ZSt8ff2nelO/1giiCssy9algrkf+jTDdLUMSe87spzWZ017Tn", + "goalCheckpointsVersion": 1, + "topicsBlob": "9XJyPrf5rMHhNIklktf3gnu0RO++JtiMiWNz0zz0Eb3OfYGxjs6JFRVSicc5wGaPVvGusx9t3iZoCI6z29lkzBX6A6WnnRYmT0S+zGFqUyrc78p0ksVDzvt7mMdYgU2QbqSKXRBTRhqIkw5iGfiIXnJ7MPVUuthA6zEO8NgfmuertShz8ypD5VCficFJN067e1GLve/CHjbxwAXOaolXXIVa/Y6y9aABkY87Dyhtb2fSFZT4fdavEbpyPnbo6c8H0CTqjf5Pzqm6JCBIsk2TMdFO9dCQgymXlGpXuBSKp42bR38rOJWkmdH99xfAj0YpPmTHeT4u2W4Cktv4H8DvdIGdTT/0/tmR4AY29ljfk5ml2xaSBgzjYW4ItaYTuCHxevyJFa0vidaubQdkj4Y12vmshjNbRKK1x104WGbrTJWnhi+VZijUgS3tFyrTVBouoqdn9lO5ngft9cqnjjINwx5o+DiUUBQFo7DrlmEyP44rqSJeVGoUmxslIreHdM3XHu79aeTF0BL1IPYbWZIT2hh1D5Zm5Y6Fq694F7ZNta26+l0O//OhaYBWoHCvWJg+YnKCUMF4dKZp424lt1BrqJLq0wy+NX2z4xvP4pNA8kNr7HfrPYEOn42a8F3Q283P3KgplRbXq6qfPfEv//ahMSTVqOtpXBqiz7SFJP3syMtG3cUCdrUqfIje0TQsgUHiep9GbTG/hB6VaLAdvdH7PK+T7HUDVN8ny91q+Co9XR/6OoORcTp/tAwzMOUse501wUgIJBb7hRJQO3oMSj6XNPpWnEAYP8TJfzgpHoTdJirIkwUqmB5AQrEBtFXxHIKrqhiFNBPxNPxx7KOKWIAzGS91tQaYvyFSHq3Bg39o+nKxryTYLTmtKubMOkrmb1VplY9iOnWJ8tCKbvWC4Wr8CNqGP6rf76JAlUQH7Saouf/D4kAKwiEzmKoX7PNeI1yVg7OFFlinv/N0AXl+csYlLTTADVwiPiYylGEyItLWxwd81oxc1bjZEbNZvOksxN+bn7NZpxjFYM8OIerpv7HtsuPHBAZ16Q==", + "topicsVersion": 3 }, { "archived": false, "missed": false, - "employeeSealedKey": "cD4vYfMluaisnsGR5L2jWSjQo+WO7bzTknYB26w9WT1Do4/TFsqgjuJuu4NUoT+o/ECBgtcBl3FZ9/ig4RWmAO6rpqibiOPxz8jOd1sOIeI=", - "managerSealedKey": "biOszbG0qSA/ZNtYLk+uc/8ycNlIWkRUQ9+VYnq4F3Zaa8mGvSjwv9cboUUUXo+kS+tpFtsJt3XfxDingAFxXaFBG05B2wpZC1EYeJ6mV+0=", + "employeeSealedKey": "nk85HaYlYchZ4nPfBkyQwN4/92nGVJLVcCytezAaPTRa6Fi/92qWICdU2UIush99w1b0FAbc4jfUKitw+XMgCRavhbLrM1ngwEmv8w+A/sg=", + "managerSealedKey": "W76Qkss/p8H25/PpLh77FmyJ5C9XWeqTVdEC3Oi/i0kRN+lBTMYvy+PFDEOBVXPUw//VkQ+g8eALNayM53Cw+Jm5ouoHpy6sLF3Xi/jd8vc=", "employeeBlob": null, "managerBlob": null, "commentsBlob": null, "commentsVersion": 0, - "outcomesBlob": "owt6yNXlsoAPDF5FmOJh4ygS1ah9QPVLEadXPcOqfSvzETXqZ8emmAKlhYe08GIWWD9jKwWqpI5/u/W12seGR7nSPbc14E778TK6utaXjtapW6+ZFlPWE2SVlkij18IPO+3/Xm8qi0+DwofkLN9YavDIxbCkd9QLgOEudVmplrLjQ5kjzCS9pTQU3dXLNVjMA2PRAO8828QjYxRSYRwB1GZ4sjmo9zGrsNcMCkuabRosSP8/rk8azrhp0KxOdsihBkhciJTvqDx+mK5qzr2QqsqtdGOyzX/n3p2DZccaAvqMrwBNeSOnYDHyZbcLUmBj0YfPVUIgfu/cPURmpuwyAbZze3K4v+01HLVpUUcLKPI0FREfr9WGEa0p34KByzWH6Ucp41gACh8e5pAJtbntBw7+lXAPfmK+Ky/4RmtwWNOSIUjP/gv0L0iaFMZm6EfYDnFxSTF0M6lvv6e1AA928e/gpIKH3FXjaopA7gkqs1o9yOmvE69DWOPca83evHrMTESFCmQwOzSkgiQbwMMM4i2Gz5EXhT2nA20n3ijqfpDjgTGuGYRqUIwxl5PuzWeUYmnsuuR77dOICYD2oTx4CjM=", + "outcomesBlob": "fPge84/d0KNLu0gV68sX2DW0FMGSnYshftwzQZoLiIvdmZFTh+ccBkVQNRnfF5omCLx2nMCQItSSrZPRSsz6y4gts3UY2zrImo+zHhS0JsiwnjoRxmMf/3b5NSao5LpWywzsR0XzrePz3VvJO8TGucolKvaZsG/GbB8lJUOQZQKyIYZcdAtlODcEvUqhz8e1Ll8qTNBdgPKUiqleXwSefwj8nd4TDKagPYtIJMNtOhA+E1mFZ7LY0eOOvlOHkJRYGu60PWkc2pX9sksuYPGKBFjkhkNWcypw2AiTqb4HD5jfAlYZf4Z8BgBGMwFblBHfLGRIwRZkfM5SD5+dgOM2l5Cihx6iNoWOKrGvMrTE2PL4uwhEJJrJQZGqSZ+i+ypKAtDXLbe6bLV440Yzj/k9Q3paL3xqz2PQs97bhPYvR3jWkrDf7J2Sa0IKjU+KwS0aC9J6c6I//YfZcLb0RMO/UM/BbY//Ijm4I4KIMJ3JMWTwV9OXyp8/miTAypjPvQW4P0070yPlsW3R4U8YkRQYXy0GLMrRn1vyr352XjMpkeiKBoRkr8CupBKXx2lluq8GJj6GDlgnxBnASqEZ2UJzOZU=", "outcomesVersion": 0, "goalCheckpointsBlob": null, - "goalCheckpointsVersion": 0 + "goalCheckpointsVersion": 0, + "topicsBlob": "3S1wvD/TF8tkzwtzW1xWiWe/pF4lJrFoRbtqfGm8Anm2TbAuUAQhckTKg/wufHHj1vyEcZfq8/GKQxw3KnPwm3a65zXfOy1rSsEI5BXpxRNtgpgnK2WVrXa4Si9x4V/R4VwCpTAcJYjgJ+EC8o08BTbgpsghTstwwrhrosgRmJOW1Rlz4/eQiMgWWLYAeNra/ihYEgVre98ERWtdRLAqDUBtmf74YwQ/aWxI7V8IDYKpxU81WkZIh4aMQBLGMHmsHeOpomM342/Bvq2YPFtGoQMB+Xq27Is+8H45gmVQ7DGlCk5JHU4JRWxQVs3Ci1LZ8gLzxkFUDv3XdugKYy6Uvr3VzLv9lky8WYMjU5TxXyYVoJTX38ENKzZ4TUgJkQwqVRVVags4JtK46tjlfFhkY4KVCO7faV6c2j43ehx8mrJHqsAJQCJadAq2KLMMfR1RAcEQhAmw3UqE4JwdTyVud3PqN+qDoLN5Rh4HGeWwTVbaxYt0gwKuLvvGGubPi6PKymfqy6XFs+D8xCva7SbdJuyhQ8HFzXtnWS13GBsi8pyGH9aO0y6PbUvVLcKrupP/7R1MRQ==", + "topicsVersion": 0 } ] }, @@ -140,20 +152,20 @@ "employee": { "email": "demo-employee-lv@example.com", "name": "Anna Kalniņa", - "id": "01a0890f-6fa6-78f4-8aa2-8f5d9e3c48ed", + "id": "01a10698-683c-729e-9dd7-75f77aed837f", "authHash": "uj34sQ8CF77wEvA5FW2tt5D5SVlB1UDWSgHut/SUVfY=", - "publicKey": "++h5SXdXobyLNksCmMTqTqMgzeBUOUkxte6uYs/pOX0=", - "encryptedPrivateKey": "6odvJdi8iaUJ0JzO1XDfh9RrjmPZq5eWCJ03TBmQKV8H6YGUk04lQcQBL8djaDgj1XvOYlEOhPT/yceLWe31Cxbsawy9O3pX" + "publicKey": "x/HA2fvl9NCkclXr9o7DRcBS8kAwDVWxp4v/jJTf3AY=", + "encryptedPrivateKey": "hm7RwPzwaQfcQv2uGWIVguqXrNPdioDFkw/UKuPvFdRf7JQDzw1/NrBKdFC7T+nqtCit6q59nPpNaUVv/G4Q9a8BD8urecW2" }, "manager": { "email": "demo-manager-lv@example.com", "name": "Jānis Bērziņš", - "id": "01a0890f-7134-78fa-957a-fd42b9725be1", + "id": "01a10698-6a35-7695-8cba-d48499ce4230", "authHash": "hrNG0lJRtTre+1wJs/L/HNrwc6wEbFVmsWo4TGbBykM=", - "publicKey": "5xi+2EGonhHKvOOezcqmvlq4wEAkOeIS2060lcF3g3E=", - "encryptedPrivateKey": "mK7x2CzRQaNzCBM8XFPyEDmY7BUqYVZNTjozBNP+YBrKTdWlbIHopbtyJKL868Uzx4l27XXMeEWj3y47H18nXihBT7Rzrs2E" + "publicKey": "GC39HzIYM5LK+kxjr7LQ6WGPZHcvVZzhCKZLG6xnfls=", + "encryptedPrivateKey": "mbBJHkaywgzoeREM/f7TtA/O0tHI2XMJD2tNVfYApeDNO2e+hthGOcP5r9lx6UGtl26/O7dvPszHBBVyIRDgL4R7djqnrk4W" }, - "goalUuid": "129beb24-7f27-4ab3-b425-d176e3f9c666", + "goalUuid": "c0eafec3-a4ca-4ff5-ae85-21fec756375a", "goalTitle": "Novadīt vienu starpkomandu projektu no sākuma līdz beigām", "goalDescription": "Uzņemties vismaz divas komandas aptveroša projekta plānošanu un īstenošanu.", "goalTargetDateOffsetMonths": 4, @@ -162,44 +174,50 @@ { "archived": true, "missed": false, - "employeeSealedKey": "jl5wfWpaMQubWw0D9mh5b1ObQe4iJHiZVUKv9sUvcUG2rXgTl0foA8b0GqWkXuLI0vGH70WgosDp5EAKqNAAQyp4uX4/icJeZIz6/9bQGgQ=", - "managerSealedKey": "j/hEZj3tGDwEtwDLxN0jHEx2Pwl61m4tQSBbwGgzzmHq0+6+hGukbeYfpvNX6E1WjcZaj3vP5LGit49W4oRmjXHJbr3awBK+0GX2V5xao24=", - "employeeBlob": "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", - "managerBlob": "2R2Lg/LKIh0IPvXuC7vykjyqOuQcBd+QISpPzAGiiUV3xNvrOoODs0ExTD/mi+FVN5K3sZHc5P3gQLcQOenQeVSeB40t5p+1J5Gh2cNb55Tqut5WNvR0fc9t2lj1I1GvH0ziM3W7Hlq3Wf5AAn/vn0FIBIm637FSpmhMP12goCCYhATe8MlGd3w+vN+vWqmkQet9gzJOTryqb/4pNWegFn3PYTygpqnvzxt/YOBb9wNL6Wi4pB8WNe17+vp1A4KR1XO+G0kIYN128HWNz76K8xGxOFZNj61y7AtQxipdAeBLmzJVvld3cln97Wk2DITzjvEAYxiMHFzS0ZrOT6UPKmwAVv8EyHPUUqa06PTlDLcXuTp2mlljLdJFxm1YRQw/7eMG97WzndWZulkbEBVd2JPoiO5unTFJ8qur1kZ8Fxg18MhbKNKr02C5Yk+Q8tS1SwjRFhy+MqpWExHL4nZRrL5zB6YWDnDZU3J5IgjAksIcctf7Xsjt3l7Vm6z98qocE38mAjnHkkVHnMqq7SOpwGDozdzX2mwer9QLKcMJ53v1ipdHq+ieI3wFQTbgsrdtfJno6w5KWDQK/Eg04aQ4JoAxVx1Z03xcYdB7nY/ivtspPFSZ4FI3RiOUjAn10YZGdTasYkuqtLYsOxik+97ze6fUIbcu0sYoQ7zFHzNCoTrups+8TCvxIMHsmEBsLrLZTX2/s1sZI3uXT7Ta6KTyj847qA/Fn9nIimGRMTix98UreenEj/Lr2VZAkdtOYORJhJJOCx4OVbII9fDI5zzWEotx3ufe7DlWRYRnYKckiUagBnknyZ15FBE5FWemRghzWcFqhOomFB0fERYtf15SKztFVsyGKibR0I4CjTcLwm3sZlajqLGIpo0u5HBCGOL7z/GOxtucs0kDqth1E2eE7UsjwaxsWoEaFwu6INKvLp2R17d2gwg7y6hVW27pFu5V3uX0gELmxaqTRq1ikkoOvWRSzNDegyBEVDXYGW0CGPqorkU4nk4UYD2Cyi15qQ6P8274cKmv/MJODfE07G+nr3H5Z1pGM2wR2Hq0GLA2qhKK6wzNUcyuXTVD2z/fn8R/lMXC9un5fXartuybhK4kvjWe6UUg69AvhlC9fqFSkKJmkj28A28RDWpas20moau4QHdcJwUSKcB+AtLCty+4rb3lQgI3AhPVuUFmGUcYCf/fOb2hfnNByabaQ4VanK6AchO3o2i/exO193f+7bv2Kbmv5lok4vlj2p2azC6RU3yeUQYzQ3sapP6EotxYgeVJVkQAHdjZraAA6mVXDXt2/wrVDzBizaS56AUAIvwzEegl4YTOwPHI5edZUna/KJbjA8YqIahAskU/AGvIZwOCjmKl0ZO1/0i57gKtyrUhsHfsLfjU7dR23Sldmyhb2ubGHUzfXGlDiaPpR+n+11vjzjhQUj2b7XcxV3knikta7+pjqmesCbv0dTJm0I7zm6au7f5xn1Ua1DxmAdXYMNnBtnLfR1YlkFDOfiunXUM/QLYqkUFn0L6/mEynDaUVXbQWTtW5mVC/o2Khw8y9Js0elXlyfBVFXcCdRKoJCw==", - "commentsBlob": "YppnhNSvP+GYET+esK75MSpYvXy6jsO5dAsvUt/gD+G5DiuuZMaFaj4EhXLt4oA13HJxCjvRkTo6tOvtqfp/6EfKsB+fI1qqUchmTFJ0ZLehK0JF1XiGS9PgTKHlKK7eNJZEtmZg33maqwZMyZvOwRY1Z+07jEtD+pvScJElpuShb6YqclS497ckUO0LIwk09Wx+ut+ol7LRiq4+WPUtOcr7EpiulMBrLNxJDLF8TGI3LTHyBVzGX2IpJ+OiSaj2xAnKZWkhvxJ3sFX81iwmc/qBfaIiKMa5r/Hjvfo9CHHO8gRPjT+A5R1I0jeVt0FSiZCfCqZLxhcvuJweuE2bqCzmngs52kY64cnIHc/qAkphIrIz9rOtgKdHvedSNCPlZ4zOdlz/Uw8eZw6Sqgh4AQgWvHIoWYjAw//uKxgIzOP6", + "employeeSealedKey": "7ZqF13TgJIuh8kfkC0eoZdJzw+xSJVXyW0JHNAn8eC4CVfMWUSCw6xCgSIF5+UH95WwfE+gXhJEUT+G53IGyj2+HlCqbI0N9nb0D9EdCJQc=", + "managerSealedKey": "VbFkpRrd39W4RA7f8A4eOpzbCXJce65wIbJB5HoCPT4Ul8l6DHiRqsXP3zN0msp6z4w7UuBF1SFu4UsEWw6we53T1Ffi1a8+DUISBmpRZbg=", + "employeeBlob": "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", + "managerBlob": "HhEWjBt3zlZbcwgpGEWl5FnyK65QLl99BK44iJ2M3kheuEpvtcOVkNjDteZ+XGsVviISFaATTv4wcqzWbztloqLwLgMoW+oq1MesS0/g4A0cVUtDIxsQvD1VklR4EY7jVluyzwk1Mln7G/3aJ5sW5g00IdSuplSNZIyYeWssXJLrE0zOj9nxcMgjdD4CQ4EkHkTnVHZzsFET2d0GebjDly32IHLN6tl5oxXNTSFgQ82B6hArk+IaTBJw1cs0pioIwzwG2LZjsS9/JZ36dnOJeO5ETu3uzl7zoIT634H6zMIc2yeDXPrxEmVqLMm4BjEReBl9iatKTFoh9ku32xNRL/DrWdDxuz66QeavcxcEzDakay3OoqJmBE4f+rv69tVQCKiLMOmvMel5wqGuBbtxC4CCczHYikWviaU0EqQ73jjNtF3ltkqzFiVnGLLELr7UBDRH7NIyWWjhyaSi2bB812i09hDJHQjAINdpHVUM59lSDWIkjvw6bHBVCetF3zSs54H9oS8Y8bdmyluor6Sx6lXxHKP1lcJI+/g8GvH3kJomlyHAmy8vYpEmWN1S58ehXLy+VBW8D8EmooSPyp1w5H2rAIYwAHL/fEtQNjobBuRsvXNOq9AbyvDSDiDpGV2PGGLB3Yv+/LWqvbL0fIE4ceP4D0TrubGj0EBttlyTYSNdcOrSPT5CgQDWkfaEGa+3ahX5pKoYFJ7RNYhYMUzsMiaa8xT+NwIKRoqDnaloxgKKRJkSQ2B39n5vUoQoxXF2yzwrExC2L0IR3ZWY2tHKC3/Jlr0DC0wHC+mMDXCfJNfESfNoZNQ47rqHJAlwztPMPJfAggU0aM7J36yY1XwQ5QQenUruYjB6nUSLKlJqL/SlPWwSQIT/Kga/L506M32JB/lAiuJ4EpPw0iLdpJczK/kRmDpK1PnDvg4p4+794hltYaU5V1+f00uA7Evj/sd9sn+9/DDgJssGkgGEwvcUfvk6lLptIwh+wBxhfnJ/V7qISZK+zcm2H2XSynQL4J7RJ3n8KLBn9hq5L8xBypEirNiHxAKgLG6mAp/dxBFTrQnr01FmnmEo9FVKIg6LEu/RKaL1dWa/YJYtpUfUxpFCaDjVrM2EkpQzWLvz8+sJ34kKYsaypfouqDrczNkaR/LfpSmYijkhgAyYzVJdQXk+VULKh74Kb1zh5wzwTqE5ER6jqfvhch6oRz8nChtRqd7lvMZ42kUbbfPLV+ITvutWEVxhib4JtKKuKPAoIDmlmjL8TMZlYqMsgnvC3Dx9K+sHQGBeYrk=", + "commentsBlob": "F0VzPzICtjMzMgSwakr6DvwAdb46vXhn9BuLzACIKNtOZ/rAJizqsmjxqQMFjqLnJaOwHyd1SrsdXX2tuswE6PgMhr2DesSY3smQm76s6gJ0b399Ptnr44U671m5UncaOIyNKqDmH8IjaejnPvIw/nVL9zlAi0qGIrsJLe+y39xMMPEBqNeoq2atqy8D9GiHwIijdBEz69nslVmNXufmdSAv/rDIiHG0zyAU99WRBerMoxx30u9pWa9E8oZlXQYKsMqut/fKBP0UDclvkkRyCNKt8Xo2+ggGZY4zRGGf9NIFct71Nm8AA0NqYCwMqhC6+xOjMSSMWf4jZ9cbDc49bQCOMsS3WPSxXGUpEC7jAMCLHUn60wLtnvXjjhvDDuItLtemL4i9dH+37xGvsRxxkgLPGk3TsnKh7FGP3qEUwM7D", "commentsVersion": 1, - "outcomesBlob": "4arGX3UI1V7qBtTR5J7gBSYTHYA2bHd5u4AxxWf26mOwBI78rOs3WQ6nhEc77VBG86jB0iYuH58LR5xMIjoxPvPo5gUGX1fSFjbUw1m70S2f2jcE2ofMtmH3WGYhja4+PFYwEmubBJn4wyY0LGtmzJ8VGyLsjEhz+NHiATwIpTh39IpIgHFIAHDWXgq1XHK6sUtMigQF0ySMtsFm2zCL5Aw7XinYxXr9mvMHkWpCiGY1mBwd2EiuO9PteI0SMb7DdFQQViifB4ABpzuOyqWHNOLU6yckAqJOznw+ft11CgaWQIPs6rVRIFgLOp2p7WPmJ/z2uum5d64jZ+aVhSXQdkfiRltpsdE7059kA/Az13h9BYSRFXSPn+LK7P9WBuQcW3bzxmt3VBZMTY6JU27+feJZKsfUHNiHzyLUKzUIxiZ99/tkpZYn7RbPpU+4C6J0sEnyMSlOYnXk5SFiow==", + "outcomesBlob": "YspAOarAK4mDTqt92BSDamnIEW6AZF8yPEAMNYZqL9Wr3xLJyXXPqMO9ZZmTbkscj6MgcaXck5tVjepRTt22M3h4Nb/wjElfWP8BFEH18ABvBrdyUHD6Dl6NGOdzqKTwKkORvrcOPU5l4fzmGss6FykO7FLhdK6ZD6L1D1jPbti9tMsIsHEeyIRCRJxctWs5avmDR3Zw5ZjJubWI5vV7S4VpnMJfiz3PoHXxe0qD69tFOWwlp4uVwkEyrkZsN6Mzfrle0HfOsMe8nwOhBY9Nb0L8VfwzyC3vI9ZnGKNHD66Rxm1ZK4DhdT+Pap6uRsA3TTRW+99uykqIYeKHrZipcNJr7YfRspInBsJFjhANeMFksSq8nIp3qY4ovu9UaDygHdCQn5eMU1uttlm/5TF+QA76hPmrzBmg6zijEA+AlXu2NTRzuSYlqGsDDrIRVRp3sEQ/LNdVSFkSuC9YRA==", "outcomesVersion": 1, - "goalCheckpointsBlob": "unSktyx5xm5VXyjC1cXyJSysYpHHrzi3kwgYrdHpu6NVPY1Pa6kN4MYkjNjCo6uEgOGEYudRMS+8MXe6N+Jq9hVUM0grYxmOza6VWwpqGllhPirarOSC/HuMyA4ujsI+mOMgsVfflMTDth3BrdviO8spN64H+JnYxJBZ22xVIwQNgQBl/+HzxrdAUrFCEWtqOaW8HmnOdRgFkOAD++vtCb5vxjL6+8VFBkBT9AWJ4SC9Zm3UZmZMIXRpT3z1jfqVY0YPYmAaddepfu8SpymxCQIfsT2PrzKABDZm8hcrMbFg8sYFiBY1BJzqp64EeYvPuKai7u8Rriuf7oNimpxDX3BYWt60bbp2ohzewgdhr1Lc5cgTql1k/iTgfmhpfrN8UwqpqI4ZPVpTSP85y3AWDc/KQaot/BfaI3hNDgG6h7CGeW/IIL0hstHx4ZWXBInY3FQCrNALQz+wgS4NbXOzOrEV/5PaKB3x+JFCjx2q1JRFqSmZaOE=", - "goalCheckpointsVersion": 1 + "goalCheckpointsBlob": "c0ac2H9Zze6RYNHJ0JJ2301sDToNQXSHrlybj8uYk7QlIFpibHC7NVecKU9lQrLnEZ9xDGClSQTTbNtPIzFN8e4R7Gi1fw+Bomnpdh2jTOp3QnbqSMHd6f0QiFySlMbvi7+O6t4ytEv+s3A7S1syIQzbu7LS3YUDXiAufAE0qEI2UGrlySogyoXGIEt//DPsiVFwc5Qc9j9lKCDtSL8pHruztgxkbLMcFkq84kQWWhi7acS3vN/sTrnZkzZFOR9/5PIcrj/yUv27N0NXWmifvORZbE9HioITJ0gHsQFmPDNFLimRqc1GJKURVF0cIAXMfhwuiUqnx2IRPZIhEiIJ+Kj9c2OPcQilEzOILK+n9T4E02JSMzsSj1QqZIPhwuR/zeZWU2B8wiRRXb8v3DI1WSqmaAVXq9zfbCUya7My5FSbpJvfJMgRAYag9eAlF5/o03ZWeLiMXBYGqGsRP1Pv+YkGwNuIV/1C9sdricKMf28Hgt6BkDw=", + "goalCheckpointsVersion": 1, + "topicsBlob": "RVluBEKFPAj7sKkw4wcoi4MuGFamMtr39dupuA6wu+Z/RqDp6lqjRnUHtbeifu/kBSdfwTZIp65y74FYLr4WWoJjeVlnmnMCM3GEAzehpEwe6pN8MecvmjHBWSNhnm7DDb4wCvdjK7xx90QimM4PE/8XHuziAWjqJOYwfxteJ85ZutP6jsBVXYzWhZYWphwUQrO3c1IpjcPzVso6+AnL+Y6NL1JBWHXapdwi0+KShF67FV0/IQOe2VYLooWIIclomo5Ygo0SaPzfRD1CapcoXHh8iZb+NrsWLA8cGG12ixZOs9pVBs+h7Tnp0eWWrJ8CecO0X5Xm40m8/WjkrZUA+Xp/OHY0tdkmsxzPyI+9AU7IfCky/sCHfFmah19ZZdH8tw+6jAiM0DTQmr3BXMPpiXHbfEHhMJ5D+kjdL2BYFaFJoT5XLOtrVL4rCjJs4oHqqcrQzPqp/sP3iUnonkNIE37LnZuhljkd1IAhHPPGRe6OsbixLukW4FGyHkl6JVXTMjSEchB4zpciapDrqrpmvs0ScELOXYVJ13iDblwHnpHEell5W+bydtxtAECogS6KNcyYZ676ZZeNH9HCksinVmIl8HGpYAjmOSnWjuWf78lX0h0+crr5/n5NqeXzAGf5Sd7WSns06eePUNWFcRfAoFZ7qgvxTuZo/w9l/h7Xzbqtbk2xpIKmRKkLs2MEapzaKqNwGIio5KI5q+/bIJFehedK6q2VAYOuwvg9m9X63GHSrvPdl6T30iPtaurmUqLYXYIEhr2+/t3FDimWoz9PRkmM/loHivXi", + "topicsVersion": 4 }, { "archived": true, "missed": false, - "employeeSealedKey": "NAY11bTwYe4Fwh1srMHIX3p8hCBE6X1XL68ZyssARX203LSq8wETrrYUzhR3wzFnpNeiLlHeCNrFttauImUvl9KzWeiemzXS9SjAIJoeP/U=", - "managerSealedKey": "6f8kYYhmmrKeVYErBnFG9yJf9bkT8VXF9Shpm3VfJQBwuINYkh5Z1WmGIV6qpYsb2bilDd0N7m4fkNTqRj8F3NCQ2y2qauyin+6cnReSUwA=", - "employeeBlob": "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", - "managerBlob": "RqE31RPjZcZDzuY0Rs0XOT4F9irKMQAagE931NquFkTWvRtj+PfxI7z0ERYdhm1a1OHRtJK7JnvnxXVz+0/e9LZCf4FfHEj9/mAdk71PRlj0lBeebCEHRHXGn/fZQQjEK7pZXT2RgmzaQC+uVVSuk933DhEzg7PzZEC5oryDepfPrtpAo/gh3bC7xOwqIwjQG14+Fh6zY9ndGaZAmI2CAXVpX356n2B9g5N6avZqtFb/UBwhR+Li4WgXEbeKOpiSEjCWPAcEwzDBO7NF/lQzSkAuK3p/14zakqSwDaZTNhFRw0RIppr001n6+LorhOdArcjpPLfBj7WwbMIFndgyDjjEY7bTw8XozMBRYxcVYq9KGAnwEWPHs1uVAMbR0/UOqDvrtMA6dWyslobCbzAMwAFYbFCI/U3veMZtyusPrHoYPbQ3N+5s5uc3lhrtFNNe2Gt2gx995lM/16NKPDHFRku9hRWwTCFeNcJeA+BQf5RfCZY/F1eYoHyPN8WoBAEmGTlw0usbU9IfCFErfCKgxI8x4LqfbPyvMwhTwI6gIrfreZ1JXGOMRE+QT466ZdZtFkXKGbQe0QR+ID1e56Oc0KX4VRyA35dq0F2uQe1t4GJ3tscBrIH1yqAjVlTLB6UPMZn0XGLVMy3kxRPyyJUwcMlRlTK0sExrkECWQ7ijUoCLqL8ltJnvqD25Prc0OgGACaPDiur+RI5rrhOXP/pLfjTKNzLSZeVVQboXVTWd7DPd0FpvQzw5nm/6lILrOSLvgVlY9EJrSh+kouc8ZSagMfPkKEMh4gLRXmvMXF2rocwugdThHnxBvuVdjEtKKU37F/areiPzn4BxlkiLtHXb4ep3DovkrtzGE+dfL93HnTI+N+4FjhF4q/ueBgSAeObYIWfSKNjMie02o9XhaGSb22LIjO8D2CmSxjWQYu5gubTHbMTKz7HR6Aaus5YnTBCbSWUC9zKbZZ/Gmx4XGvd+arVNWsiPwLAQfX5AZ0/hy/bINEdINK7Vrhx+VqYeC65iQdAS5uGjeAIWbLu1DAzGO+v3q0OsGvVcWTSy2Y6eH8VZWNUik//RrdsdWgD0Gtal2brvOREOrtRmnbz3M8+DTUd+Z8deMq3H9zS020CAlxc5ffMqNa+rWEF1XfeQmxZ2Ao2ESQs1aA7w2nRrqTASxzJfdSYXvDMLOeIM5MZjudEGv4iZjD8msT83bOEXFMKV2aiozDeuaXERGA/8qK/gSLyfPae7y3mwmpDZEbMWQ1da2+QWMHazf9YMVS/8YrkoaxmemQOwArlpvkvu6OCR+lorGtaK+PGnpFLvq13VeLrxSPVJGmJ3HpQgZfx8yLcZlyEj0OYwWiWevu9LBBuTA3+7SkMc2Rf6LFtukA9nqjc+UODv5sZzvWLAzUPSv+OI90dReXUGCw4VuyRqY86kBQj1erieGoMFYaWfkn47LVdPKganf5MWGqdCGrjoajGnXaa+kLbdhIlG3clvVv8lomerRutefcZ34nnEC58NI72gq+w9ON8tUVBw4IIQ1cW9F8JNn4REzVwn0wrVBflPbYZnXAN2Ub63nCyXyLQeVTvoU08O0wFgFNb83j+7JZiNDL5QZaav3yd3LEyEPSlgGdssn+ZLKS+QEqE4VUY2e6x/cMxGv3cCMgti912CoF6BObFHmjYSGqbZDNo7/2ODyMmYzFBtzz2IgDVV64kjVWXOTWIUgXJIo+ck3KFc/PE6hGT0V+REA6cNLbJIcRXvDqhdTlsWqAPInshg4ipl0wxCXg5wl0t/Eh4J9y0aE79jyhxR53z+AF0PJ/QmuBBwAkVddFqjptbFSKVJAPA0CrSEDOIlMlFvSMRhBQvw78PQ04jOhP9CV0a3nieF2nGg7LM2g6y0tiGPYaS9DoTWl4TzgI67LTEaBWhseC0bFoTaxK2el3Bq8sZ4fJz7o+S+skpaox/tK2r/3nfBZ2mcGEHGtQ==", - "commentsBlob": "HpCWKTktA/u0CpZE7XHJgmgPAgdb1/5FFUeoBn5rEL6WiIHa0PK2ouaOVUhHr53TmkrPumt6GBu48X5MoelezvU1KwCLfodlzgp7JriChx0/TKm2uMuaKdaiNpPo70dhzn/xdCYMG+qRWiqEcNx6jdn8eTveUaZWeC+wyT87QM0LHKrR1AifEa+4WEBsgYtoHGmIRhVQ5hPeYuL0kzp9ivfchsiuDauYYVfhHTUXRJTk6SPw0iFbPDeMb4NPXwfuimsDv+LEtPBvgVCznhUPiEy/rAa/9YgzgjRleJmD0tSSv8K/sxopDaay8B9SwuZpLRIdycevljeDfaou3cCQQWaVLIlJ+75go4uY11PbdTWb", + "employeeSealedKey": "ysXXRMufK4PhumW2rhtvkX1KXBQ4gxLUr70GInu9UUXvIFhJJtMZqx6AAZrW/XxWdmxDr8QWB2akGcfQ8QNGrNLYf0VD6cEPjfmE47d7bgk=", + "managerSealedKey": "EdASxWBDUgam6mLL8T3TWiEkhU1Io6Em5A5sXnf2NwCbVQlVvgff6EtRmOFex/L90mtrEqiRWpariRxWqrU5BbbdojjhxccZjb4kOilAjT0=", + "employeeBlob": "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", + "managerBlob": "FcICOZSlwbHB8t8ecjI+kada13AJWvSKeasrPEp+FMcvExsSDKMoh/NnBN56vEFs6ZwDCZ2JqzqIqyBEYO4aLt+011mSsID8mc0vNUjtkn6M88S8eVmjnbBEHjd6/hP+xyN3ReCZVWMU2DEC7FqIzlbLKZZ5DEwJt97MYf5n0/gAVRRBecfb2t9YOlT1RfQmvTZXL7gCKjATw9TmjGXWpEUKhZRIF1wV8vmmAIY3X5a3lPQrX8huRzPpzWFt2JEfdLKpCZnMtkL9UY/BF8s1cGWHgMGf+iwFWxTtRHgrOr/i1c+V/aZsLvb5ZjtXJWI0IxxJ53CUNEuvW/ACo5kzSv4eoi3AS6IWPCYs5p0KG9Hv/ID9hIOCp27WXpMgKlsgOvTGU1MG8BdbK3rsxPPFqJMmrB9OFPoQzqSTMcvfkIe6YJoH6vQqLVZ3nd5dpTdyEq9x0PGFgwU1E7ej0FQ+wzTNLPgZO8izcU1Qc/NrEdrdgMz6XplvPlcIn7nDyV+AwHvpqB1/HTz5/pN6NXCMo7qI281LxbWQLrYJXBFvq43vDhtpsMZ2wSVoAYZ/oc2WCyxnOClJiSvLA5h4zfO7N8uE+kj3fqLoi1LPzwYjwuk9x7Lw7loXBjTuZscopiJ/0gPVPYVmqLSQONSWDn9IyD4S/RdKv7UUhOmvTWNbJDtmVzzn2GscW+dbEGxGAbNUXdlk12L+Hih0VfkBKxiPOsTewdmCPEUzNckXKTEM6CKdYouSj6r3HqZ8bqwDfJKr8vLXpp+ZPb9pWUJKvLgpuqn1+NMInQT9mDDFi7g+5rSPtFvyqrwHRHbdM3TP56W1vXJLjxD2yOJetgnx7I/swNQtLknzmnHHt/jO+LbiyQ/pctWRo6R/2K6uEYz8D2dVlcFg10PmpezeXkThRikRL7jU6poxos/ddwy6LL/B6F4eqei7lcHmRKXhvs7M46wzTFMIqYveZJdC3/hU4ZPIkSQbyZ31eHy9p8np0o8BnWd7d+rA3uN8t42e1VincR2norEFXy7n/f0CCEm+LtYgujzi2sXTCeBlFI7v+/5T4Vr5avg4sb2+ol70oQK3aXNAcdLmzKM11VJMOagcEktSjHV0fU69FAKnrKp64h7/ncUzetKOF5C2OocpP+EjNv7MGmpadxEqdJuKoC/NbSC0SN//W2k8CkQ2JYCoEFw6wjIf/GnpNUgZASd6hLoEnVg+zvIlPHm8RlPtrBYccT75KtWq2VoLdO5Pbur5l/OSMvcUWap6BkX+xBFbxqIcjGlogmwmUm6GFAHpcdR+pTZTiXQGsfSA/Lv/1GoYrjRVNx5Z5toONEFEfR6QgBNW0WXEJN3KscLEs0+izmMV1S2ldXaaj5/piCbSOjZIIXCx4X0oCiDFBk1gJMBL0IhlCiHhiQlabW47TjFwBiIPCqODcEdlEp9nz1s+hziSV4Zx1nqfuKTQ4b0cVfMGRzCeXmhLyX18FvUW2f9yF7qeLz+oxsCWCkItnMnT6PaspWRdkIq1Oy/q1csCVmfWFlsaaFBsCV9/GexTk8YRyXYEbhHTMvcWuX/aICQKMqCFIaRKao5FLR/yPaXwV5jTWUoM/0rWTi/ZrIkIhYhONWbkB0MM1P7cggktRupwOtaG5BDeZiip8Yo0nTC3RYEOOltIKWwaLuUeUXuV", + "commentsBlob": "IQUxut4t7FXwkNjEu4h61GhrT38U68AfNE7VI58I1tuXRNbSVXfCaCNh3QuSkYV7h5oJv3b2/t/L6MdCr4HHsH42uW7kXOPOMFAkVPb2HdbvVJlKtAxJpQqowSG0EOebEOEx+5jRLsP5qt2gsHoMTI0vsvCMNgGI8RWltiKm5u0SRp+0R9iKrXh6NkGXOshzidhsbopAF/WX2bVKJTGFVJiGKkepDRW4G5YGoHNPMTz6Kmah4ljX5NXmcYe7PlZnYs8ErIW4+7XuRKExD/1Nn+CDQDTWJEAV+0hp5CkvGhS1/qZNyfcJ2XDvu3LDATXQqugBqo+MXfVmSLcUmWhBe96Ae6cDQ2B/R8gbRiXI7/ig", "commentsVersion": 1, - "outcomesBlob": "IsoQcUcEpfNm4LSwPmJNBcrxGniKmX/+w/I0Su6r95/iVRk/7e98odTbVzbqoMaYZ39BmYBNggtmww9fbl8aB826OMCUS3+MYXx6ad8p73qvvmwt7vl/KcXUgGPtJ1wHHD0Ib6wqwgvYcjMt0sqG3AohLC+p/7X+fPheT8f7yMaIdB6n/s+h2uTXVyqKjBGqbsXE36Gbmx4pkZpA0R6ESgziu2I8VvrD5KsjLs7WJ1W4plvc4u4uYnKkcNKVMLWep86U/TnrrbIQzZp918r1YDhCrGhjz38pGChtSu0PICGJiGJcbCwQAbrbBYYBcEq/101HcDW3Xo3y6duA6ISwWUObDzJr8vkF9NgbQLqjyuzYzCDrkD7dSE+lRNkuvetn9EJ5wK7eU+NHXQBqpLP9mGKS+IBQ/j4Tfr5X8Uh3JSY9OkLkJTlzTIcYZfjU3lzHSmX0Fd71s6LrLfindOJhBXVTzQWy0zM1KehNmar56nT0EqgAAsoTFT0mhdqmWKgrlH9QK02KF+tdtiNWjffxeTSClR0yAfGaZbMXVZ99W7pr6X5rO5Z+GmjXMh94iPapWKg43BHOYosHKkAaRCM9UEt//3NgOCl/q51T1cIhRP2HIAl0x2VYKBybl2L3/sBjupPjBLwXgV6agN7XBHSlRR1klPs+icuPWHCQPBhCOKyrEcHpW6VQUBcPvS9cWXdrB6+cDcxXg1+SLytQ4BE0Or8XbzTt6BO4LocpmTIYzgxE0Uv02SlzcxTnJ5oK+tDQ3WYFVQ0OOhfF3bzRzSBRl266Rf+wAE3t2+362MR8uIrRu8BCx3znVY80L/2ky2SAFIkr", + "outcomesBlob": "EDMikhNUDlADSpwrH93fRNCvw39UiNuEmiLTcOMGyxK+u+pnLN9EGobSip+VTC9sjU/eeWFpiV4nnIaMMNBlygNo+si/IawCwb8+XPpcW+PLhIAeG7hFhGAuQl+2a4XzMpJ56GGsLDQlSckFar/qXDgpxuoMe5sleKioAj6t09C6e1FlFUSf107dgcOxVV+V2P/oSxm9453H5yHqFlSM40Kxr6HbxuI3dkn5L3uMa996k8JJ8QR7Jhni8yFnGb4XdJ4GiRlwtlpK5fJDslWCy1ef1NGHSDLFGj8XEWiKN6g+Hw3UAyIgA2J7WdRi2HvGYGNg12pkFep9C6EhcnmWDLfp3x7uLXqW6cY1CMZw/Zzl8jW16yn6sw+U80tyyq5pmOYeIzegGeliY/yCaaICYCcImlQdPXY/8Go4BWpTdwBupdWv24EwT2xutcwup6beLzCwVaL5K1XtoVb7vQjq0Lb7YjskEOkLgkkzL+ozu+9wXIz/L1FUX8olVJNlZnpUeHxUwIIoYjh5rhIUxfffdeKNJvLMHHysGE8PUqiXM8Hgy9wd3VLgqCedf1eoEOQJbaLmYElMd6l8LiUZuQ7qBURLbxvm1/9a4gPergxKN9RN3B0yzgKVB+RSl0UlnJpGUvd0GAZL38W4MbwB2FGEYmyM9aU+cW0F++gKYkjCd4HCxZkscTgICRKmXZ0+6CzIUg3ubOZvAmT5NdOluYDgokVsPqJMR3FO1dM+nSiHGMtRLnTFFQoF+UwZ9erSsJYHGyUzLxJdP4PeyO3PQ1GXIsL8Y/4TLlCyUG0Aq8z79JrF8UF8IXMFE1nm9oWGM/RNNEPL", "outcomesVersion": 2, - "goalCheckpointsBlob": "qw8DNG681YJR3bsJOo2eGXCaPIOLztXM2+Sn2gdSrEBFeE2JnmAPk3+EQXzGWi4yAV4sPEZut+eSLLMCWvlcNGAIa7V52xkT4DPoMmYSVoQ2gBLDZiwELdifxDNjwn07I3mDh8IijA/b4U7TitMcN/v7v3bpKrGYuSsDCOgzWvzpyINCqy/Er2dAJD4xELm1c9sjFVR52gwHOPK8kCLTqjdhPhkW/jw+9igCeZzMCeSENOFd9b6frOo4syK3RCVxEH/ntxXivnHP2pjsCqLOu75hrTXOPyzrJEr7Yuqea+vu2UPSeS/4SL4eVp1r5itmq8kX4s/YrWP/1tUUcazoOA7lYQb72YKkXBx2TqVXCm0Vitgi9ygUQZCPzFHPrdAQMe0GFmOxoOhrlBgjMIlrPLEziBF33paOe03bY8jcpi9/sA==", - "goalCheckpointsVersion": 1 + "goalCheckpointsBlob": "IkkRjzr9fV5pxjUgUCeAN/lle9H7objq0t9rYcBo0E9TejZiS70Rf7gZv0JLPJgwC3XpUTug+Twy/AkLdCyxueM9THPKCRqIjLT7rlQcSv7lwFI26punFPRjEYnkBy0Q7LJQjp/uPqvbS9CY3FrJwex7aLYRd+wZrG/L5CSQz7H9b1Vhgn3U6Nyejx/3iiAkjklHVNk6Wl8NBTcdNcmKvKjnxVxFqwROk+lDtBnbMqJY012B9tbNjlixUNDGAUYsx/ztQiKotH0usoklz0bWxv5wp9NmBD/KEn8DD66kLtiSoEDVzlrvSvDNVBcGSz52mFlzymPfw7EinvBPE/xLhbVk2fNrrNoR3SZYd0A+rw9e8MRYPdAi45B//KwsCE/B4bxvihrgGnJip6xqoTYrBklGIX2SSFDWHHLGzwMybhPTTQ==", + "goalCheckpointsVersion": 1, + "topicsBlob": "SK/Ncq9M2TvZxQC/Og4P8L2eGnCO1BUA0YbN/ik6pmWXpCDp9or2QCvYB0ypPgy6I3yrztEGRqXZ1UAbE6FghI+bDC/XWWrztLC6GNdpu/X9wvQOWbP3KZaQDs7JKsn366ndkQQTpyozN9F3wXTr7yn6YzDvA7bfil32nFLYr8t382lybaxdHjJOQKYph8NyHzUE6uX1HnemcGLp+WmAIZnTMDGM+CzFheIZ/lgAekE+QaqsKttN8qyaKwg1JqXv8LEcZrqrHNR3IRdQvndczYRoiyU/xGoHupEG/Yao/K5/egpILtv7fZzZxvRuKczuXNXJROdnXeAPxQ6NZ3QjVPMsXp5Rj4uf0/snkE9J8hPHIfIPBQOHfZqt7XkeD3XrsRk8qXkOcHnO5u7UjGgvTxvLX4oOCXUPLwwnX4pddBXTbMc46wzPanKysWakkNG/FnNPxe0LX86aKNGlHaMsQB1VeC2LgNdkAtSIMdQq/0ukqSY/GhptVH3Xc2EzHbbFrZcIEtb+jkDxHYEAB+HGaN8Gr64X6mmY/pe44apXU4XDePSeVjDvKZaJfJJzRNx5/ti78hX+5C1DrlKbnvlDJloAaGqadzTacwZqCTMjFMl1bLmmAf4Ue8QAcCtZW7lqf7FD64Nwio4ZRINqVXxq5WPQEzBN/wC7bBr1na+7KF9Knxtt4Vc+Ey1zg9Dq2moVz3ptmI8TsZKb1gQaQEp+HHe55+DYqg4VVOvcsWj7lQorXqlGpoFi2i7XUnThJl0hV5Zn7/RDa86MgugKd3CGJBNhvssgKd6WFErJy5HzhNM=", + "topicsVersion": 3 }, { "archived": false, "missed": false, - "employeeSealedKey": "+wuTzorxXQXLeoiN6OyYJnQ42PtDFihBkH5KfU5wDGP/s+q4DdAxhQ+ONNwFVPkXZ8gXl5MkX/JJri2Dc6f3GNdJr3cYSHhnayAKm0oNHaA=", - "managerSealedKey": "Hb4U4Untu84n6Tln/d3zcSNWcOujKvBhybYO6M63uU90+HhhfgO2+cIGQ2eJjjjsNXQCNNB6bfbqt25riVWViUkFx2iwueW3NbSisKanOTU=", + "employeeSealedKey": "S1r2QJEA4RJApcmnHMG3xCpdHe8VNqM26WScyFT6qQtrYxbAfbXeSm5lu+UZ6Zo1bs46iNXxA+AclcuKcjN13y9uHxK+kwSpe+kNsTTHN+A=", + "managerSealedKey": "TdhKlZ3DGhIZ09qaugCRUHlxNEyalHdKSLxy2+8fgjhPYhYJZ3ktkZ6u/gO5l/cYLPWSq5FJggPJEZG7r8C93ip/KhO/DBcYlcx1j3z/kQc=", "employeeBlob": null, "managerBlob": null, "commentsBlob": null, "commentsVersion": 0, - "outcomesBlob": "Cr6nDGxR0MvcO6wxOudwz/r2+UeZ9yWmxLnOO3gcW8t8SOKXSWhRQnGdaDa8Yk4HEenVcHooMQvE6QoNnIJeVvnDD2KhgIZcjzzGv4LkFpFq1O4yX6azhdy4nRU8Y/9y2Z9OFCt9qAmOMOTodmBURSbyGClytPRup04VZwiEALeR7941e/0M2oEV5aT/RNBwQ8lxiDM2xY57xa4/ti3dfYocTZ8eM86NA+PT8Rfn35+miy+uAUxjlVmkuYPkZkv4Zz2Oi9WdOHGtHqfTsGpRqrLX45Mya6iJrBRdgvOgs1z9E3KimaxmV010q3PPOpYCr2YJ/2kpsFzqVj/jSSnVUtnb2/cF/TgwoL5782B1UjEIYQvKsG3OuLs2W4d0Kjl5268efAU/C6l58AsR72b1In56tdLJs65/KCuR15Cf3Me/WK1bON2GsjGuALxHzaZ8sGZ9EjBB6tuPLsk=", + "outcomesBlob": "jqCE/rIaYUZd2s5tf5ym7u04IAO3H06XgGB5sdVIcDQMLk2O4FhlqjrKsFQAJ9q7RJm0M74aJPRgP5C6XLrB97/1HkjHHl/nP/fWTnYEfQLA0OaPf/H7XDM9ajq+maOQi37m4dmM8vhA0jIujZBw38E/uVN7e607lXeAZAcoFStVuzrB/41WAyISi354WL8pEK9+R6Ge1e9+MWmn6UfDG1ZF0aEMHoPQ9cEaSXZ7oP46RMQiEXvtIlGK4u9a6hsNkv6Igh7PiKH4XOAhOtHcbW8xmaELeWUFrQY83zAsxsqV9r/hpvrcpmWA3c1VuMC2t7AxT/fGR2osIoVVbq/LuRwGuzzIKNCaHkdNZShBkpLZx+Pe9lrDH1LMtfYbjuohJrj+MYUMt1ykDnjOCPpTRm5EMOqpEITBWwU22UcT+x/RsN5dFokh713V5C9uoqCcOvDCmrzKVFWR/VI=", "outcomesVersion": 0, "goalCheckpointsBlob": null, - "goalCheckpointsVersion": 0 + "goalCheckpointsVersion": 0, + "topicsBlob": "gLBgvES5DaoWw+gWdjmEgNMlzTgxZnxphQTG96p/h74dU6i42wLDzI3lLf/sVxXoIFKCtODA1g3rSqiLceEn0sBFxKuWv/3cWoIp7zrNf5wxGEj7MUbqsR82zm4qMrLdvxcq/L34KzvuTvfqLMe/KmdDhSJT+GMk8rHtrFyTn4Nd3EoZ+cpH6ZecvaTq+Y3wxdBSj41AeuNfbRiInr+gnD7mGBMxI45qL+tIAsmP+YWOchEa2TJyVoBz7bdJ+JLS6T6rdpEYREovnnUeZj6MALbaFEg3yn0uzJEbYsX+4PGj4Nj36Mb7Ojhnvt8bnAWSmW4J29RfkcDGhPC7VmfLGPYfe2uWpiyToYW0ea5uF7IRzUuvIYujPZKaJ1M4sw2AqZquljfHR10SZGaTKWc7bcKslTTQRE7dInLAqHdo4KFRjCx9fT+9DgUS/5YWvfw=", + "topicsVersion": 0 } ] }, @@ -207,20 +225,20 @@ "employee": { "email": "demo-employee-es@example.com", "name": "Lucía Fernández", - "id": "01a0890f-9d52-755a-9b5e-2658a75f96b7", + "id": "01a10698-9b43-7091-b299-339187c5fe7a", "authHash": "FLKNgp5MJKhCGqrXPPvxCvRO1PdBQKoLPbph7zd0F3c=", - "publicKey": "vIqUvAP9v32pOiPtGQnZpJd3JHIgD0ajYR/fdGUtSkk=", - "encryptedPrivateKey": "7JqTKH3BuWeE/LVEG+Wiuu2jlo7ZWjvMDhMSfDFm929XhmScEZ4SNFx+hGRhnYbmMB4ECZvmHvq5xdIU2B56/7mZ40rmcZCZ" + "publicKey": "dsgCRHPyMjU+OSF4pHPVHCHn8gFoCwm7wxqlwmHv3lY=", + "encryptedPrivateKey": "HoomNAwoCJ7RVsVIu0+nI3RztwZVMse0jMhc1P+lUijtxt9LTxostLV9/p08EbL8ebtdsXNpq7rT56nfHsloTcoust8qNnOF" }, "manager": { "email": "demo-manager-es@example.com", "name": "Carlos Ramírez", - "id": "01a0890f-9ed0-703c-a181-be1b161ff45a", + "id": "01a10698-9d38-772d-851b-f953a8589060", "authHash": "QacadFTqcq/9GuzI2XRTjlsfdNGCKoo8T6yC9rXcOb0=", - "publicKey": "9d5kvSYUor8XcJ+v57+2kE791ZSB2/MDiAX0+Vk2jmo=", - "encryptedPrivateKey": "57XOvbgEZz9UpSWIEb4nA3F6kee0bWLsdWBYCFpgcW2tXsrrZFMLCy3LxB/yMBywMv9dolZURcs08HUDaFsKVbXMTERQDnYJ" + "publicKey": "Mi9xqYTADsoaXCGgYdK/DPI6Q8k/iXtpLIGsESR37hs=", + "encryptedPrivateKey": "m0F5tan4s/fxCYjSrP4k4MKz6UKFjVyHvxvcqQWiF/MjBMKBRJoGAwGty0nHIlFtoW6sg+ovgMPEB1l946Hr0hAIZ9or+lQy" }, - "goalUuid": "5642b846-0f2d-46d4-9501-cc820c803b42", + "goalUuid": "54ae22b4-64cc-4d92-8572-34a8edde5496", "goalTitle": "Liderar un proyecto entre equipos de principio a fin", "goalDescription": "Encargarse de la planificación y entrega de un proyecto que abarque al menos dos equipos.", "goalTargetDateOffsetMonths": 4, @@ -229,44 +247,50 @@ { "archived": true, "missed": false, - "employeeSealedKey": "DgXFKBkHyTUo0V0vrrlMSkUaEk5WjrttkoXHZcVBbTBmTUcNUoJH6C14NJ6GoCjyWhDUB52tQNZR8n4B+fiqCLMWLx/j62yWJDnNUNrVHEg=", - "managerSealedKey": "GoKG54v0AGPORpBCv9DeR8pzBYXTC8lTwCHZo7ynO3sfuIt2gfzGZuWGBdUyIFjbW3SFgCNm/HBq/FLkh1JchVECSeIAsg1gTG/kNGj0FUk=", - "employeeBlob": "TSFSzmMu6Za0of+acWMRtLBERlh+Wsgutgldagd6jU87r0V0EyPx43BViOtyTxaMaGO+Tn9i+yX5s8RkfGUeTp/pqf4sJG3zH75BwP9xcYgEOCHdLn2AOIY3kvysq8IrSXI/g+hdVnEXXj2Hk/k2lRaHpdZbNbsIrTtbQh22Oy0aqVPSkTWi91E0859LRZqQ+gGSOmj6/dRRETNRLNXSKU7PnzbWTdC1rY+PjVlg5U/mfWDPRz4ABcMka+Bgy5b1jd7YeaY059+asStFfxyG1b1he4D+s4y8xoo/k/JZ/MzxHuXsjDCKXoYN65z1yY+8w1e1Gt4pDwAANlp2olgodK/oC6AfcwHfIsyto7IqGbzc3nOLvrACaWCmhVfm4e/H8r9VWg8aZ6Gsdg2O1UkcQ75Qq/tK1dvyIc4Sl34ERw+pa5x9hIASJW5Y3kTbCvbCIi0ntIp/TVGeTYPOskc2/CaWXkXs6qOAasdtT9vjduYD7oGiRuExYyAgkQyx6g7Z0qPzIAE01THtxm8NyC5qr7twFwyiLx8mpVZiC63KE7+DGEZfTqBrDiP1iIMTUF40ZSYbfFBWB/8/ZY2Li7VqFREguJt0IDilUnuCGo9O+YqEaoexnLv1h6+ITEtLxPfqyckTJp49Jw9s2RlX4nj+tM0Jtwrw9HQh3nbY7T0Q27cuMdFgRP9QpdlpZbN8+QiTD98RehOZ+RNcGdTQ4umm49pIbtVMuAySuZ5cIDnNwxu7vqwCnObmAAX2oKNrXs6G1sLFMz6rPrZ7opVr6DtwOYdAYXl+nuR2SJvg/Vu7j0QxqW7DcrfvNnYow/ctRR/41XYuzvqNv+KBt7f/V2u5nZWiaVnyyZPWvEidDhTmIddz23kRtkDK5v0h1RK5CWbp5C2p3VSXfKqqWXDx9CZjNqLA/z56y3yOpMWUHOyjd6FMp4BoNc39CterFyVYcQnGxmbdt4K3sHFtefsjFbgbXdKGDOcwI5uX1NKaNI4dH9O+YmymLh4dXWdKvm6rp3v2NqVA4eZzt+k2ZvbRe9dzJ7L2GZ9MZyCgZ+ux+X26iZxXvdwDj9u655Oij1TpQkl6ONSNCwHVzqOd+8rMUdmu6TtdzaoPupyCS65bWHc7Fm57sgzj11L/XX/fRAYjOfjxHc0lyj77+jlgvC7Asy0YjOVDBMKNm/OPsmCmg8+8fNt7225mNumx4vqisnI00DcsF/LfyFbDfWMuT44mdWoezCalbyWuHFRkbzWai03WwlEZRV2tPFYRi1HRURnvncjFzkAYVAtci+qJ85RtZyRfy6A/JuFWBta9kk0GYxzP4xpPHgARpU+AUKESMwz0iwIZ3Aj7ngrWZ8eOnSb09+Qp1lu/iwr0p3kmdb8tYdgMsX2htCuoYETIgNrxMLHeL2r+QOUfRT7q/HH8J+gtN9WQjBlretqYEd+4XJb9sIAGAdW+n2b0xOBUWIkm34Kdfh+uxjz+TTJO2aFp87czpRLQWBYwhOCCLPutnByisvKUrkDlhq4WdkdtUEWXBJCRENsMLNBNuZRqoneL28B35ffjpjblsZlHlY8hVU3ldHV4ssgJAzJwI4C+ct/WqVmjAGDtEBV9XKG038FosuGKnGOQpDsYJviQydMXAWGwvkNdVKxBlcAsWJHv3sU1ogD42ThvSveFBJcSFFs4dURTh977MDa8a2sd0w/3LVbNR1Ql0pSOSREhiiYmhJCvuoue0YZgkHR5MzXi5Zb+nbhHpubeuZ9cH9MLF804azSXFBVmsxyHAYjVeyOYgvyxNNmGv0BraVUWkWpO3rul7gWJAH+YshRDxaj/qVF9BiEaTQgbIyE+Fr3Yv7jkwukDC+gmdp5apQhXrNwpAGMQy4LLZL//0B9PTjBVNvauXFkbrrNoa1MC53LA9uvY5+V0jDO36vaJnCARBPT+fMKh/lTNWxjDAun2tglb7OgLOgOGE8Nn508sT+ZD5TUjf9W4qptYvtY9D7VKtWeayVmuaRn2XWK5TSgb7ZG1bYYj7TqVLQ98L0QFWU7S0G+d73esaDZHR18Th6KhEnOPr2E+wiMJMIxfPLNdpVMMOBugC7oETgG97PZRXbh8obXTCx09O9gAhIOGiUBXXT7lxkWkTj36yJ9mN4NXWra3Mg+24TsEFsO56drYIsUATWfTjNLGZzjvss0o3WmO/tI/dK1IB+fnjpqJCYvtJkcY8W3HGz9y5UoIUO/RFtncfS6/9aMTQwmlqRkUH1FtMuhveH/Ix5Sg1rkEYUyth3XpOVJr1334D3ATnh/9jYAewGMxJUM0JC8iCJtEVHv/G1m0qE0cU1chgZjANWE8n4flYhwjrn4nhU/tnDv/LJVVZ3YGda/3+w0sMv9T7vyi0RN634wD0GVUlkf/9puJgXdrw5RQidPkbsIlwOJ6EWbBW8SbQ4xp/iDAUI4LR+Gt8cXEFLCtKaaWrgs397ItA8fDc+51bkJ/wuwZSuPZ5Eqh+ZKLVSzoCTcJq3LkA+XraVEnRipeSV9B3rhKoc6QVhFqDjt8PyFh35w+B7SDwoUtnYdOlXmWdBQwalCaLDsQqPim0QGMRA==", - "managerBlob": "w6OYzIZIp/PliMeuXsWoHvsueBvXSkx3GdSOqt3NqgEupZBcs7z37LvrWFm7gWMroC/YIm7iM3RgR6dE0EqMiAypiZYCmllWh1HdKoh/puYAHWYCzRsdWl5E1o1wcEjKCSv1wOl1qIQfMWJ+HCUx28qENnjkXH6tQgpIRx3dP90Sz3GKU4jK8dWHO+v7GBHJr7udc36C9xA2n6dQ4wrKBJZuWxsTF4Ml7U3e40SdCacg9/bpEYAIS70YrqUM/AeG9/JPrsMITZ/uiyKo5Cr6gGlhVrM80lbDF06vLJ+XGRLjOW8I8VLEnDIe6nwDqT8TbInRc88YxG2dZWbem++Ts3kKs8cFemOTZyYz/356l1K4waAWKY2Rjf18qkjLHTitoI53RYaflx4oE3sx3jw6GdLLWda5lLSbp3kT3YIkV2cWAcLBc/AyrQ9RtF9W488bp5iB7qGpGIrBhM0LCCKAKG/zWzvYZO6DAjObNRCzSiV58qm2/0+AkkFksNBQEBDdJxzD0Iaydo0RNJC0uvGpJyEKH/nzCvhYnERmV7sDO3jBve4pXjWbCHCfcAkELeaETJMdw7rut7re6mxroUB1ekvEZZjOzK0Wcrejm3+8/k4k6Vz8reD+Ox9pN7WYqoFEN0LlgtkqJ81PCVQs8BXcG11ZpaIXDFbg54uXkaoStdQLwE76/F2nq1fYC5CuKI2TGlCEdxsIrsyYJ817WrL1VCTy02hNdSD8zQjsHE4uBT3/Gvth6YfBDy5cXmTdd95z33W/FG8rLkkLxcx5Vb8ArBlF2pJLUeRJnTYeNn1ZHEee7sDSBgu/cBlN5nvcWgY6LSuUM2UZTm87+RcXHJX5Al8GQoqWaGvh2ZhBr51cWRJyYp7n5G/jXl0DrjR9axicGuiz09EE0QYFK3wcStC2XbVrQUheAw6mxhuvTSnwrgQZnNpyl9lsKdvhWYeuSzFndBnJTi2F2GocFpg5oSYrpreptDnhgeXIJXcighcDNhknqYgpd1WVFVwS1109/oQ5yVGr6EvRW4QLtBj7pVfxBEd9hizP6CXMN+vQUtX3CwwMM/edsihRIRAnAoVa4+vY0pQNYVhPE73NwTSQjcfoUp98P5EwjyBnF6lyCorpuERW3o6xXH9N3IWiWQpxIvoyyM5v2L0/tzPr2cxs74uQe+pS+ncqNvbTmxKu7muZMqh8nsgGf5Tv/afYq6HWqHs2D3aRMWQsSPsP9xbZgLrI1uwzip0Dyhi8sXk483Uk05Z1Z6cLkrrvV+vdd33TYqheDJAi6hePpd+nDc3Z5GepVpmnsHbGHmXefz0jCVuz3BE0otflFlBWWvqPPT8GG5pS+oto6h64Aw/vbxe9pX8TqyNlQeJMM05TM5r+3/28R5l58y5cqulJCwi/WaRRFJAnX+tQcsjsh1q00VpJgz01sDm4spAS9yuiL8FaxHXbt+rhBDnShnM9ZmMBNCICIpFYjMl2Hbe68V9cgYdBqcaPu218l2M2KFTRoivEMOpAuC3CvM1G5u2zXjwUqROz5wv9sX7qZqNiOkYxOqKGQZptstmgZtD7OQkQInM98BLIYkZnme7VMuupaZiK/vs=", - "commentsBlob": "+zebOVTxpa6cpwQHXSfiIl9N8gUPaSIOwSBMBxDt3egA5y+zBv/PhlrSD+hBYWUa1Yk5rmpid2XcaSufhB525X8KWBk5b6KNbn2+whRNWyLyTEzBQpk5oNUDjMOplCxHHc+xaImkoeww16WP2xkS8gwJQuFYCcGmlWdYWArhw7WvV4USjoeAgxZ8GXgQJY/ON1yblEjVC+4p+T4GlU6z7knn+KiSBNmuG4wox2coKzjkesMAQEWYdscCVl0KLU3KFGnP50ih58BsZqsj0QlNCFvRFl9i8QoaSYMcHfqV42inHRdPKXF3M2uRmetupP8NaHjiSJz3h1V8vDcxvLg092Aybwkikz9RJLo5Me0su0eiFmGH2tHREI6PZADYMw3eK/g9cqGm6ces/6GwA1OvnywFH3T2aVisfmqoVhOy7qUdlQ7tl3Ymhrv/bA==", + "employeeSealedKey": "UZSpNbRggE4igJY2xpjObpPVjp+TZtCsHDlEyPDzUHJcc53am9r62/iNusXJ1zG4zHv04IoESlORsZegBC27u4tmhr7aF0mPZRy6AjxREEc=", + "managerSealedKey": "symEeIYzKsamWAnMN4qKwY8+iqEFAiZs3OVsqLC6lXQGdGcVGbuuFFS0mkp7TbsvG3gj/gYOsA5xE+6QBXCvxfJQTkT/7ZanqNLlJfkQaAs=", + "employeeBlob": "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", + "managerBlob": "9Xs8sYAPQ+bYIpOwmEYke/TwNolnEEzICyLd9Xfv0AEYdtSBZszHS5MZ6zkzJjNpmlbAEZ9qAtAnPqfXfRNAS6uX7X+g2H1jH/P1PZyfs3FKEkxItMcGosm5eJTzqM2VRDVFpsCmRLwzhdTCO1a9Fpk9Ut0veY8alNxkw08qbtqQ/IHz5yfvyNt6TaZOTMWFBmb+LTmJgJGvTI6l3wHzRHajTbAQVhvofx664xMRJzaSyI9sAs1bMERy/3ljQ9yKguWxTz9pFvVxazDG25twd7YTTpK1b0hyHgE7M6KMe3JR13G9/b4BdcbeuaZ15bhTdYQilLaH1x/ziXIhkaVFAoXrYgwVxc4ZUWNsXTtExKiv54ra07UgUpCJfjoZq1dv/SzI0OvlEAO9KkjzXRnQWGTALARwYkHzcRzHkY7gwOTI7Yl8E3CJu5AOjHKWqHXb4s1yJvHAES1oqe5yN69D+haZAcuwrPMRVQMVURoROzguXfuLsOEn0g69DIM6wSQJ5ua+1VSOuiUejVUnqPzulk4F0+CxueDq5BQo9/s934MljGVFTdalEQw4Ym37hqLJNVyLAeCa8EaDr2xz+8Urbb10XFmFtRRgEuWyxRQ2j8dGNORao81iiMGL6FzEmCjjQrHgFEVHU/Y7h6BzKM7z89mcjHS9dBUocBisZWN6B3B/tAJe3YKtnKhDk/9iD98suDpkpyx44SFSCCNN/Qox0G9PNjVza/UpZLWp8xQvyBXBOxVUZmwmW4RsvtSHTiVR3Hvkgik0vQj1mIXOr37SP3JOUAe6+PcrQvsWBwiWQdz2QnXIT0lHPHPdXS3mZzhKeBq9sSfdJrPYnhXXPtz3CWUQUq0c/E4dVc5efXddNVbCe5yNtFC83fL27EYA9NSzYEbuGAYonNLkOQANKorb2fePciYJfKe2X9D6OCUUIg+HyXz8H89vt4mUrhjC+Nz4QDe4fFSe6WbLyVfWByscFFa1afmaHxIOaik22fjPGjO2oR9i/8hu5nUVMpuxUfOJJNy/W1ZVx0X4+lmFujfhGECQCBod5yg6tEsfZAMx7IQcmQ/y0gRSsLUm+JRbsCsNvPf0o0u6fdwEM7tTk9WgjkyN419GlqmGblUP39aUzSTWu4dsLlT5dQP3+N4Df28b/Azfej4Nh1nuX+MOVSLskVAuh7fdiKbG+htTuarezgwk70mVeTo110uLcxLV/1Yc8tkRyrgfvASJzM05GDJZX3kcJQ2kDeIveYxHzZyVxpQKntsZ79cQvzrgofIEwc+YyF0/5lg4c+8g6AunFAJqVnPOCywxYPIlP8XvCy2VpsWf9f0QfEnR/ew1", + "commentsBlob": "HMnv6iaB5h+q6oHZu1Niccrvu8rgK7ZrsJn2Uc3jXPmw82BUcHPdKS1Xu47is+U6cB1NTJrFrNi/uIelJ9apNKc8T1PmAtEa2gQzB0xKgk1TTqaL9Tn+EDF6Itv1KpYBCAmd1RJJiH6GGJX9g/GK111dgtFmbXu7HHVBEMd9GC0/urFX16NjaQyBpkazrmvLOO+j4Rht6pwaoix/Z5MCu5w4AWw09gVF9MPSgq8wse5i78Uozcv32aAkAO3fzX8s3MxJqyu21+974budfrx7XLLN8Mg8OWpTyoOrVfPPW9EHxpr9a/lf81Z1+rMAQPaGa/7Y1oT5AHd50A6cAKLj+UoR5WdsGliDBlxIrc7pAu1QyZPTaoSIzn1zHqc9NAKQacC6RM5n+vcGuMg485fYOkKA4TgKjP3YVcb0U4XZq5ivWlsbFiTQKbzS7g==", "commentsVersion": 1, - "outcomesBlob": "I4RrlpPnNhUhtFThMcc+A8PI9Gs11MZ8ahvU4XWYA+39UohE5j1LfAocHp6U0+igh9q3yi1qA+e3uQVxcHdNOd6cckVjqQTIsIm0Gq9EkeU7wp58BFS1tNd26NZPv2WcB8kTvF+HrSN+BsAnZwDM6oBXWjxAzL2J5GHDeOD48kCeSE0UOghHd5um3G6hw2Kz5tsUMZnVuu/DqtjRmvsSRgX0rnPsYrgUJiT+PAER41ikoQk4Pe03cNYq/sZZMZavDX/wIRTnnxA2scHMV6aAhJt8cXLRQuSBuYQiypeNE5ty86cDO8bKjMnjDgFdZdF5JIfAlQxMddy9QsqC3mrfkGdtr4x/xedSPSypmhCj5oTLncrhsrUpxHTz32RKi02fIuoiW8s3dzok0AHwBcR3sRsxA85BQl8ay7kNM5wec9jpmbv4QrIq2hL3Yc0liofGaOj8ZhCzVZEu67J9CDI8rSyvSpU=", + "outcomesBlob": "r/vm90e9Diig3JjtBjW9WFtWR4gCeqt2WWe9IOnYdyylDUV28mFCdu0fcgRsy/wHW2+3a/OGiUkSADEosL8Oo+FKu5e/RBS1PNz5xJNbQR8yDQj/DkjmFfKCZmGV81wFGYu/Lejjh1LNfAVCeFyOauKgMjLUAjbsr9nZ8vxDUilLjrORqTHLU2VVjJ0877OT3YTxNpNEOoeF+C1Pbk51T+jOOjY6jtnHtI6NDN5Dt1cf7O/exejslVMUPEmCpWrnYBcc83DTCaemDUVvnwVLr6if/mlTKqXyTQL52bspGt7FQHHOFXbYofWxAR7orPQp4uePKtxHj9d9GlwLjdonfZJxhOcQOnjPLdWE8+TZGGpkqO6YJ26LVfaX9n1THyj8wWwYtjfnbG/KTRlr6UhBOlUsylRaFq1g+Ende6dnodPP4unIccp2U1XGyRI/3HsIWsbTPCBtWGRi9ty4OoORjpst/wg=", "outcomesVersion": 1, - "goalCheckpointsBlob": "M0vptiMPDWI7Sk+5t3fv12GU0jaLahp8OVqgl4PJrDNMZbQDpjc/xy1dpA5t9pTOfEpr6HAY2JwZgeUeX3wnlmf+8IrDA4Ilzzdbrbw4RiAwGI1r17Kpm7kRSQcDuPPUrhUKg9YoIfBYonEeAS+kM6erG8t8HRYhyQ0qbTgEBzeoDN6w5uWeruTJfdbS8LefoniLCWCMMUkW+o01dA6VPzjBvxBW24q4Z14GWuabqdEwkUwi975qKcepskvP9uB3RcWYPfeh6Wd4t86ys7vtm+MBUfnHpGBdxCZAJBO8zrCBzYZAMv7Azy+l6uqbz5swF3qQp9GYvVBEDRVVpXnoE1FIV10rDPQuM3COdnBlipDbLl5rdDT2mXTsCRuoUgyn+WbUFV14qSoCuJlqXw23z30pCf7CpK7A7uO5aDS60GhoT23JCPc3HNQo0uMrjNdJXJ8i3cIovnpDaTOcv9N5KCRT/lN5qVeUfl4LMM5pFoyYaOcFSTwXUdxuowTIMIr7h1iU", - "goalCheckpointsVersion": 1 + "goalCheckpointsBlob": "29YkYFVLWlN5gr2TFxV5uAuFkHXeUDKgdQTutVci37oZXloRMpeS+bSKynWQ9AFETLZC9analwOjT2t2aM4W5fUJbS6S2czycRykXclT8rsEIESlGmZPpiTe8fnKg3hF+EMkhm4K42x7uNg/E0CaALwlrkZpxsognKNzKD7Vga0U9ACJqmmI/S7dfQ3VPmra0KsMG5VFIsDgA99De5dIpdgi6+lygZu2zGegc/Y1NsFGyRiWMb04a43jlSo3wQMGxtnWDIBRazA5buibTy+LwzH3kNKiKm0ktHrFHohk4c6TS5EDhX4xZfF8qBNh9o6THS1QMegvBWUV+//cH/fHdwD2YDuhvXXANzumUokwsBSdjbPf9dzNH+UEiCfPtlzPB9iZoCfvFEZrxm8iagUvIluH2fK+ilAHf5TW8tdzkdQMfbqVLFetQAKmvn4o0biUcTCKWTGImK9dsU1gh3qDs0hlS9a4ZN+ycUuMNtEaEHcsUX+/lh4XaudsfAQzgXqBzZ0e", + "goalCheckpointsVersion": 1, + "topicsBlob": "arGWCbXZ0QxG5QIqFk+PaWB56TnVqPsbfGHzbEdU2vVuPD7orQE3WrrSNh2AGJ6DKyUY9e2bJ3/7ZLuu1dvsl+RQGSB9gljwINytT+8prOr/krMmsS4JlknrOMmBD+9tI0K5Rq/+XeS4sFV7LDeGCofzkjUCWfQp9ZgpvU/zgGcoszbV/+PHT5Ok7a1BgeRB7MBe9zSjJEEqszlpaC+z1/4C5UPvupBsHKjycBu1Sw88JpPUTs2H/Sj8VHSdlpqCz6gq7DW97Yn9Ya+pT9XcBEwgJ7KmA1wDwxwD82tmdCPmlFq1DFmcZxHoo0srY7EgVAiNejZNG4Yrt/uBem3eZAPL74QkxYRMeKDugeDYyDR7syFEJ3j/TbEzel2XHBdOFShKTL8DGcs7NHNGHytuRz/U0znSfp2D+7GbOPEd2/i69P9SbeZl6oqCaR3BS0upqcs2XCEhxFdgWkKLsdgDGLx4RmQwms3NRi6E2YRnppWpsIjYreWetlXhA5J+fu7Bx9R04NZU4Ox9wvWjdxpPHwA1npwUf82ADboEBqE+gljhbAiKT0+L8VaJIt6M3fZYbQCOdCiiMZsPiwfxUVcDjbhy7ygydvdx8owLDH7ULFifJ4+NlcAI3nr6bqSdZLBDLjzl5oAimE+d0vDejKf6UVwC/3CzbM8C0H5AxSiXsGbfShi54zALzlH48INfAB2xunyOyqcOTCLvj+uGNXzBVuvUDvSsBe+0sj3iGtfTfNx7Kulmp5AgEjoqnvzeW3TkozwmU+4=", + "topicsVersion": 4 }, { "archived": true, "missed": false, - "employeeSealedKey": "kqMHwNAgnuSRbgdVGYlf59wFMoTYUKZA6r80fS8z0XNaLuduNzgmtFySBjTPOxzUrtDb3xP0WwZOpdxA+QznKqdLSB2zpAZ0E6t1rDeI8Yc=", - "managerSealedKey": "Y183iArlNowZXov3dq9Mmn8U03fid7iOISLUY3hnHEHIX0NzzEIvrEHBoG0m6+XJJM5sy6m83h6b1cXLZUjQldcH3tLD5EouxyyQyJpExdI=", - "employeeBlob": "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", - "managerBlob": "Uhic7IYQow3go7ReOCWKHt1Ekt9vFbYrkaQgJiK5rXz11VfsVKsapdzIP0YNlJi2c8PpiSJ+f/RPMktnkoptshv7d3NoCP+QdcbvYvtw8liHFNk3UkDrzXnf4e9hEubb4JEoAMUJhKw29f+kN65uVL7zgN6wBN1SXHd/8G6l0Ap9wWbDfCrlgL1uVodkPJeVbN0ztWuMxX4txVqkCD+/LHTRKQ5YmWJWRLZMH2Q4ol8cA1TkNzT3VlbcND8hbjQG2rqs/yiqcA+PAST97jrxuR3t7IJQ6TFfzEgy+uITJq9luiWmAWsw/+21z2l2ZVgrsb2eek9hEJgyP6Bp1VYyrJyXS5IR4IS4QEIRQcD+dy5ubC9lPJO8fN6UMXIe4ukEkHZ+NpzztcvMeo5k3LedkIcTu/phhoK1hooJ/3SniUDiWNEZeZgxmM3p0qNTGENJYQMb19RYzQugpAos0JgU1PoJZqDyoGsWWDXtl2aRhZlE+3lNfSQizmLrRiXWkwjcLTuVs2dm8P/E8XaNJq0NS0aHBM/lfnk7un+jfW7zSHIkWsAZskDXIcdcjP71WiFltuCRTTWsixHbzL0BGHifhIbnQvadHqM/RuNMHl4DSo1i4GjycmbsNdQ6OwPlXzxG2tB7+QgGPZ5UiFIn1q5KXn6VCQ7rduRJpcCtQfFjrnuVr+00V54LyrbF0Uy/94z899uuPlPOEGTHdPUTNIgIYvlyxqJeOD8cppMIpLUn6DAcHBl1v7wbb0PtGrP7Udfp0iKXK18goWdxuYuOocKf+6JLfDP35T1TdSb4wIzxr8hkn042Q0KoBzTVoDDymjfoseXYHFcuDKmItol8yHRyocDphGFkQ8UjWwg9JN2QDEVgoZfbh9HnsJM+gmgU0QJN0VBiUHxfCF7xaES9kdO1+AhN0tcfg3pajfwCyocpF2CydqmSWWibGcamUZJNQoikW3K3W76qP/6gfw/yRwScvv77rVWAE/Odn50XevyEbAJmn/H7fUx87qYw8uAKCDWzKR+FdDhcDRhzy0oBb1NB2aRbVzJ55fueVVniDyIpJJIijulfPZ/HGEl9wpezsrBBma8pVjPr2UVkzGG8QuVu9PaorMlkWC/dC3hCBx42QWBtXc1+tkF6HsKHBmjifz8GiPFC2GPobdBRacZzWsmhomhpzemIF/aeZFe/E18+8upXvR8D7IWKiC3dAbDdRBublKHiclyJYywiHm5cY12SZ6ivXsshceU88pSdjlU6kQMVA8F2fo5du0w+FvXbRu44wGolDfXc4I9AOIJYtfC5FmiEn5/v/7o8L7mUHNiP+24zfEBLu4+9aUo5yWOTvSsZ0D6X2sIfAWqNG6KGv7diC91ZCdk5ev2bpfzhO2rbiEWJbFQn7F26V7SHAS2uxitmL+wJzMWfpRnDTk809lU3hwa6ue8ihwm12hUFFx7GtnzRwg6wCfpTUx4WqDYgwNjkYdmUaQgiL510Fmelbj2sUHna857Q1LVQRZHzSnqZ6uormyQ1CNyIeuk+65y0dWaMmU7TsZ0v9JKsCFdL9WtHNlituH+SuK7DF6Sssp0cL0qEJ33HYfeUJLwJySd/w6egCCaI7ORUbS+IbSZ+V2htlWDx92c4p+ndGbVJjlpnJYBdFQf7FExcr0ylABHKoKCjvdOK0t6EZ93V6A8vAvjSLcoECFcb99lNaCVaX8IxDjw/35Tm1FtCHmhXNPeEik0UQNlXhUpWZBxANQEt7lx/8P5gfz9snow86Cu4khdk9Z3H68kiQQgVgGhjOJ3JaqCvjog1NwBYe0ZReFaw/qv9BFkTuGXIx87n6RpGta+yyKzTuxbOjv4ET6Jr/QRooAQWkOKZJsYCsQ2uTBwVATOXDi6impNniWkp5VyPW+R2aij3NdtmU3RRwiFPsEl9jPb4lhpCgFHbzA9RNYv/iAlpyXwhFGvYT7kqz9NpbKwfDXLiP6wWjL+t0T9mXKWDc6tCZ9NF1WlDT+w0F27ouR+oqqX9kggjCvimbs56hmO25c7J2jVLa1n3AXwdoZmp7RtLY/yhcbpefr1iVng1GA==", - "commentsBlob": "eexfcnzyBDcT0cUlsKOlbgzdcE/NNkILyeKozrq4KXfUDKC50XdVjO/7BvsaGt2v02zRMV8hPlP+2YcUokftIkudQa+YPvylK4gD6sQ8fTpw9TGxNT7jEeXXt+9s9Mr0mZpZGXDDdL/bwaTrg+z6dLgUQlh9OXX0jiijZhqgYtlj+4tuVjOLrx3eagaGJbNWOKwW0gQMDXVyeF6ezPpDYxRSemfJSEn7/T3OTrIm2XMap6WYIwbz+EiXh9OorlpB5Zq+EgM9NmIWQHjDpelyz1kCHNxZnX9b65mpcaZqZVScPDHtdQpuwVILi6djo9h/UreEay6h/EIKvdkkxWNbM/xOB61yGySQ9iQafZFKaq5/UpzSJkJQ3Tui", + "employeeSealedKey": "mKoHGNQ7Kjld1f3S0IioRy2znDIHme+56zpptLA/dyytYyVhPq6vHD86YKER4NtnBDI4t5TRYBDeenIYh7WK28/P7bql1k+N9lkqjbQZcQE=", + "managerSealedKey": "vF38VE/w8/TcmP4ZTxcxn56+yEezi8MmMTOcfrfK6zJePGw5FbEgDTXU4BIFXV3R1lj8CuThCW0gYozADzFgKIN4i0ILxsve4IiSCKVjaPY=", + "employeeBlob": "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", + "managerBlob": "XaNNZMbavHNFRXbRUwBoOjLCyYCfGakA6MYYqSa1fPF41SDG8wVUSiix07FqjEoGjgOM2WUAEDLkKQAENC+n1ed7k6W1KZ+gdGMwsOuF9/lBS4Sv7+p6KqadYMlxeZOuAliH7iogbzr2js1dd7I3I6ZZsE8dNGrllJBbYQPM4hQLZKWZSJe9qnrFLskfQm+P/vA1j+IAmBczcBO+FnBNWYbIRYJVvyYKMB5oYtH3uakfdqG6c3whApYJcQhAB1VnSiXVq/umNDFsfSF3IjR/RWuYZZbKOET8/1AWcHSqbvZ1cAebQ9U3kFYekxVtfCza6mxszrZFJihB94nDK8HtW04GL7qgsLXxNm0G0z2NxfX6JrwiBqPxKYlJ1ca6UVksdC6d5++HDm+ThvTHODiGEr2ob8xxVhQ3nZCmwstg9Qfk1ncYXgiMHdkxRzgTPLsbDPKT7sBMVBO03qrxKOSK/cL+uF/3LsEjIxMgsdFwm+S/eQmz1CzccMY07m8Q7JIokvbbERq+ZRQF7bUJI+LOrZVvu1DCgPCDXsrfB3C48lM9P50JHTRRQLo0dp/D3O2owk2pF0xhGsVLNNpejRulFv5wl0ZOaO0k/s4w/9l9wGEuq3EUzt4JW7J84D20KAMTaEtrp9jUvots4k8brxgWv8MwkESSacrr0HRFEsMwJAxJXsafa7gb0OvtJRaEh5yLWoHu3SnzDthpGSgb6GfG8r9mLVteUpdvu5SKf3wuq/EhHN/DCDkC38LRCbK1mfES70yhbD8IPXTGLB01+sCI6fJpULwMAWJlhUpk/st5YuZn4S+j0FREsKnNrXi8YppJKF0qvmg7SlZaoRwCekyK+B9/VhdbpSGob8kOUktX0hYIadwzudGaudCN6g5GYkP/AJCFIH2lr2oTBbsWB7ftmjUNFpt7HJPLR30GusF94VMPZkbyzeZWBqA49mAtM11WBa0DOF7rl96mNP/V31uB2yAaBtkdkMm8G9LpuifzbEEB2F1L/wMxN3iKUa7lbgCTYGoCyZ9nFcUjkRFCCLMD6C7vVvycQ+8+iO54yirOUh91YCZ/FFlHOeMtV2IOIVXsV+ttNL+l2zjea8h31xAzA+qYlQjYd+wlYxtBkwS9Kor207G5h5RYnhq+gynUo4dNtg2PqrW6Lhthj9nOEMsYV9TmYrmOhg2pH/JzqLbVaW0rEkEDiVXwIModWTvyslSG5hbd74pF0of9e9dE+Y0QlsVOAqe2/bfRLE8YHwkWunwUBq0ow4OUqH5AsXmiUf8CxtnuJKDj9gf/PqeEAs7svwDCYHZNiznClCxMuU7Ir6fa6U0/VVRapAS6UA1xGUQK0+tjnQzSoHcBRfKDvq7fmyIe4woUKEccHUaI5y7QKJTAiUo+rzPgo7omBzR3SVRZC+0iFhJK1B2ABEr32TupSV1NqMAV9TJdJb9d8NP2BDQIKBC4gak0/Bb6rtGFKGikUtibNSbSi6ThWm2pHyvvEd8q/GkV/RlOY5Z5pyR7WkaviUMB4UxwE1qUTsAXuIZunlxRKf0aiXy7xp3dDqjC3f/omsQXxP6KCd3soRA8CdSxBZLT2zIo3aH7DgbhDls9oSgb7DrkgjFEgalyIIvW5qm2w/XS4NcfkbmaNa0YDUNkiZ7RtHndkYCW6FDQ7oMBdQEyksPOOEyySSNHLrD3Lsm2xf1I/P1Z57f/s8c+6+NsZqIIEFbmcNbg4YaYdFnpsGPaiO0o3hZIbVKNwxAPsM3VVA==", + "commentsBlob": "T0kw6OS4kzoPYMUC9xah3jneJ8F+f+1ykuJ0wWgDmXvJQMB+t2GpLNO9VKEp2q3eghrHIXXV15a4IPOXThyi2yo3Kejo618pDt1aXbNDFTebU8c//9h4J5MqfDDzwT1DgIJBbYDeGSYfxtkXhKe/dJQDRAl+6p4Dx1ELwwJNipwxUAt4/8bH9QsGDWtVf3SPa/H4Q1tB1zXBm/bkKeamIaqFmnRmBYBW8KRbOCwVr08WImuz3jmX9Pb7Hop8v64w2rYh6kNsBdHspuwgsH96PnDvd4FCcLRfSoPpgRLBhyfjZx+BuELN4+eH+BXSYRz3LtTsJaysvIVb36h/gpFo8zWM+yCuWU6udDx9e60lFjER6jjy1skGzGgs", "commentsVersion": 1, - "outcomesBlob": "MbSeZWo+IR3UcPrpVNcIi4OATnyTv1ANO698gPPNb0R18MAsyU747ClFoOND36j3OU2sCGuS/Vg0SqTt+tKNNDMuKOplCjkuKaFI9ieqdoUCK17ugoqzAARd2plu+D9Pg+c1e5zd+ZdLKud4C2lxGFOyvNRhw4hhBJA5YWyaQOkmb/lumsZ9O2J87YfrXJ1SpYjWVaXfI2lFYkp5DIpsJ3bBAXsObrq037fg0iTGYdAcJ42/7fO4R2R0Cwr6Cpi60V0BA1v6wiKdyqFpWcu+nSYeKpL4c7zL6ZphUs+o2SzuI04AsKJsUWc8NqPhuSUD75t50mDyBfrzkkkymdJHgCM2whl+sqIxerI0V5t9me9drFj4mKusJjzcySol39y7CPuhuO1gqe0K6YQ0D/pxOk+MuT0s0zBLQvIaP0TZdTdddbzu9wunyfEbkyNRZA1uohGkLFS1jftY0KTB8KQKWqANTQ0rVOlWUeWFYTDpuHgx5IqRPXUDl0Dm0+iMI1fI1MnOEJjFNhIICuDwSwK2Jbsu7doDkjj4MuRitxbdF7dcJbEZvFv7eqNkTC60VD1JEOqsP6E9x7x0qW40JLT0Gv3KI1/htL1qB5+pomDJaYdEoMXIlnc09ZFS2QgqF4xf2ggGFJiDx5JQrpbaD+NDGSS8WNMKjEOVH4rkn2KwTuR+aiAQD/vhmhRzXLb0Pq6I8fjkjpUQWvqO0BPjm8DInva8c11GlaO+qCIWo8c0hu35I+FW3DGbpSz3CQpYg3gxX2Z+nVZa/6BQCdoUsU/P8bjpItXJc4+YMJMnUnlwFvbF4OubtWswO3RjSv4La4C2Od3CcHo11AXMvrYrCWpyAEdo7MaTGqxpWek0", + "outcomesBlob": "f34OlOvRGkGAaMmXi98rqxS1O4+7BuD74IeTXRHLfVZRuAxZRnff7+2XE00ivGl+brTVSFL5TyvbGIiiRPIGjphFeVWMjeG9VWIQtC1EMmMQ03lXY4WlIlORjxLlSUNpKGpiW0uk+h7XyRaJl8g6alUWf1J/HWs3HhqWoSFNVmC6HIiFtgFzhs+VTCTAloGnK3lYPXW5JzmOUnBzEGddNSGtZHoLbxQZsJmEedQQpWosPmsncScRypWWJbCX46TSd3QsgKdmH6ouDOWOYBmKV2VgkWiOwttiojxI41R9wpWaTXG1jzWVxE3MECHB4U5GmkdVMQw+r3wm6TfrQFyfEJ5UPmI+kqkUHtIhZhojKdquYJZI1ZV/wkqT4+9s2X4pkxNFpGFM9cOxV5KphCFW1vy8kHIktw/NUDjk8WxtzPtqKn4uQLF58gEcs+Kpl3tNVbHktJDGvF5qahBLME9PwxoVR7kTK1zv4BYl0AMEQ94EESHlHOxc7RXitL3Wket/ycIkLMNxIntZ5WvHPwc9kly2H3S4JY/XcfoO/uAgcuHg1nhAU6MQlZ8zxRhD7fwWdv+YLUHmDaaa8VNjjObGl5FNcNYQss9/2dJRL+Csidy+RMzfD1y1p/Cv+HwyO+J54WDR2z+7YJLh/HE0yet1DbDlrF0o5sdWo7ROmJyjorhhor7Oiv00ZW3JNOBHUmGSFJU81KrWzfAeYttAz9dzCFoPONol+gp/aNJsI8W5xDdnPSTaoZY6qKeETYvW3f+iiKgPTDIHUi+irqNMQMTDUukW9mvsL0NYtPBUnZ/jZBzmpV+4cIMkrTCRLjluuGjEgjm1lrInafRquTIxfvAZHJtwGxpJk9OB17Kh", "outcomesVersion": 2, - "goalCheckpointsBlob": "hGOIryCs9ZP7kl1sNBMot+CjP/ineD0QuxAdc+lishM6uf1egghgzBALlsJK7K9V5KaHgMsVFLKd6X/ig3L7kCL7oDispd1lK28AhSUqBitXjbdRRIwXxTn5oavfZVk+CFiPtwbtRe/WyrQl1f+3EHdEKUIgT4DZwsOHv9uZJB5/ejCwujhj3w0tOPQcPWtHWiuzrbnmGFIGH+5gRkbqC2G+WTFrygPL3U1H8PSRps0i53hA7meSiFQCW1fiYWsBZgLjfzW4otFXuvqWF7WpGJe8BPLrGw9BK/8XWCSbG9x29LWjD8S5+i/6YTfOg1NdvckX/3Dtcc5CW0L/BYP80+zDmtEKQ2GPVzHw4qZpkk94BFzkxN1r8qJTWEoAi7+S4JhAu9nW9IST3+60LunYizUnj/sCWD4xfA1jk+ETrtRoqajd4IeDOH0Wm5o44Z0N/l9c3m4=", - "goalCheckpointsVersion": 1 + "goalCheckpointsBlob": "dWCzdtGmCUf9jpCCdCw3BkC4nKZDOVu4aqi5t0FWV4hvY9pulHZmUBIqwUTs8n/zclbQeVAvdJAOfy1KQkYGXrQRoPYSxCw+cVV1NHs8xO1n2jXcipEcr3CPiSO6Zfek3gOW5isgIcBNeDziG6AhjsSL2vLuVAgsjuN+YNhqFUFaqfLyUz4IJpFp5DX4P1zhd5mYj0VtrdzFuj5M3J2BAgY5jZv5AaPGB6vP07gYCAhav/eP88w8+sGLU1aUSFen97IQFNc6nJjO98jOpUkSoE/8gvojVAvwdDwjNEJHkBqjeesJGjuqlxjJm5blCIYKiGSObM+Hfz57thvvPccSE63u3mM18Q9q3yl4j151IeY+I/5AIdKqtyClSgMN0V2OhntfgUAJ02xvPGO7v1Emzwawb68xlqAtn+rmtje9X3wE3ISdN3EcG2nXqB6hAtze6MF8w18=", + "goalCheckpointsVersion": 1, + "topicsBlob": "tOeTnJ6ubVCARvkSK4vzkXnd2P9AMAPCPxiAPan7RjGPDX6YRvH9XDfV+1NEyZ8Qnr5ihWSsnLFAMeM5E2bSDDmRn83NmZcBAtELe+AZKmuKennaXCOiwzzkMP+qHDbuCPWuhSbwJG8fKfoofLplPG5Lx4f5+5+10MaquXzwxN60uMe1+jX41x7D47OUSbZZGeLrR2XS/0Z6raSL6Xoeo6S4+9AMQLxvkb0OTD1aT0+FL+1PHqyPeLTiM9k0IDHgziJMGMVGNemNhdmAh0yikXCJm/v8MkpVm2fk3wp8a04wMsmTNyK0TkdZRqRkbVydOXFVBr5CpuLuGBkJ1RCRiJqdq2zcXnRmaJTJ/BYogjTvG3EXlIWXQcVjvXBM6DkP8TcJ5sqG7erR0+FV2vgJqfPxZ2gHQIN7GuWphw/s6D913UB5/98yGdieZ2w09/7Vb7dPTy1xcHfhFDvv4Z1icFvJXS0iHuJzqVXdwtSYV4Brm+7QI+oByhOB2qq0cABxSHWBGJ4UJwGXdvs9kzxZU8QCDb6Rm82+U/nrm2/RwFY4KxxYQVKvt6z3HwPpqnCDRWbzeynsDlmomxBkJopsgAd/MizlJUPKPqRM/UmKgOGRQJ+D+qZF5YYBiUK2okaSs2i5bn6FB/yylBDyY80xb2rpbj2YdSTfeilhxBFiGST7P9p9P7AY0f0Dd7qkE2kIEgYklTXbbKpYlexWvBQu0674Vaoi9OIHfIL10HDid6D1rO+56XRSlAfbU1TCM/nd4igwKSXRgfgj9GAVCPPG/NX8djUfVlk3g4u3iV1alRJgroTzynSCAc0KvY7/ez73huKFTjI=", + "topicsVersion": 3 }, { "archived": false, "missed": false, - "employeeSealedKey": "tqR7OV2LpaGEFtvXOBORbKinsnn4/9jcAi3mHIqQG2fhWUHns4EEQ4cb3WeOqqLemEzU3tzToAaObU99XH/wVdk3JcfatJxtYm3owukY++U=", - "managerSealedKey": "Z2Jqbg1thkCU+Zydw74oaOB3x8+2stvCFOZSMDF/inlugpk2UYmuyO1f6pCzuicHF2NUvEhYlx34zjmX/aKltRsFOAtzCos46gbq/88MmdI=", + "employeeSealedKey": "p2zzNY6Q0Vor5QzqAqjpv4nU98548Fwc1j8tzuqbQlr7fiE5mST66YplC/mFLh8aIDBSwriRmkyFBELEhSc1qXMv3hjLgrUTyZRSu4CGA40=", + "managerSealedKey": "Q9M7TUIN7pHJFd5TgKjRoS50TASr0IJGEsE7PPvppiYA4zzBXWXNHgFDESsDHbElMkClP4vJpdt/VNttWQgzSsZw6VCGqBChhws55h/7Ks8=", "employeeBlob": null, "managerBlob": null, "commentsBlob": null, "commentsVersion": 0, - "outcomesBlob": "+v2oeJEdvzqBBJSEZeMXf/jsrpFe22KxjBVLo8ySc8IzG2Nozb7OVbLfVjqeezRkvguXK6W8dxoYUpLe3Yg1TTL67pVgeNdsH2w1oIUzI+MnKiMocIBgxQIRvDC8Bje/6Sr6CDSdWjqURoipLBjXR8xReqpfVa41PB4hjF6KxAe/kZBWf9HKVEc1dOqtnC2tpcueUPgz289C7gBfm7bTbJtFa3l5UMbK31ix8Hw8lYXErmpjrPHUwRTDQxSwW2cqjZxSuXgYVzq55ge9p01wcK4uU3uuHtZ+Rtn5DgsGOsoZ/Voptk1lIdxSKyKIQVyXBcSSYdQ1KnLDzU1YpdqhmKZE/fZ7cYFEIlf49t8HyjCthM7SyxRveBSdTzB5litfX8aMcuaVQNwOScuZHFXvEQunCgb+bNpz176XzKnqj6G5tXTY8xftkxiiStNRDEGjsZodqlXywuAdMqA6NLVD0fSz8RppJTxFv4WutA==", + "outcomesBlob": "2AhZ9Q3GSXyor9p3bpFNUuFI632I6vBcuGz5XeCmYw7B1rNsMoua3vyU0qfwWfgrM/VLhDRgutvG0ZC0PLesLp4PdrHnLPEHD8ZtKzcdxrvwLW3e7PKLJ1XmxR7hLqbfjx0UfOuglsA74fUhQx8fOtUmqSLi0jEwgzv14/DyMMZHgpTBQOeQhfK92mNV0gtebaNhOs0nbvjVWCJfpobOeR1dL4Kg6oQVqqm5ZjV3LbIur3zyhlNleUkZUdD0U2T76iKQDSLaPaMnOVHUJvHBM3hIPNHHa+w3MecHiYQhiHm794/MXj7r8lrUl7NepW5WPhKiPLhmW77YI0sUdaH3UFtTUdCTeEPuBrY1kYIwjxDDS7tf1uwm8mCls2QOOSMObOsq3NDKsdGN4qMCnAW4UXw5PjYVJg27mpl/z7EoQznOg89LSl+hCSAZ+972Y6zBOkOcLzB/GbNJ4IxmNyYExQ2VpeIUBzJhDipOtw==", "outcomesVersion": 0, "goalCheckpointsBlob": null, - "goalCheckpointsVersion": 0 + "goalCheckpointsVersion": 0, + "topicsBlob": "bFrkwHTF+eGWEMP8UxFEJqnNf9s3wPLCFdGxJsIJvaQgsmvwDoO42klm9rtioefvtHi08t8hPhK7/fGdY9wAjzX0zBtw32xhuz06F8vITPjt/BgMriCu0GuYrw+hJvBcSGHcja6tuOfNtwTciwCohD7KJjdb500xQ4vtEKlWNlRa2UPVNkiPYIDLOGzLkFVS/gAqkPCV2E22ihhLqlI9bXXcqebFHebb6y0ssnBc0JH2d6APcRPQGA/LknT+N5y9rnOx2bkI82n3rcWzVM+rN/w1z4Qe2LI6D1QK9dcQ6DC9XiIa0OwjX3TR9RoSCwIQiVR1eBgHEExnkhPYXEzFG6wJIJgs/H0rjS/EzL8VahxG4IhitS1f8E45RTzp7DEsTsgW6eaL3zQ+cwxie3OUJ2CwTBxc9Ny78ITlhEiLVN87pU0smtvddLxpd7mbAmVwGgRJibNmjrdSfxCpJw==", + "topicsVersion": 0 } ] }, @@ -274,20 +298,20 @@ "employee": { "email": "demo-employee-de@example.com", "name": "Lena Hoffmann", - "id": "01a0890f-d538-7c03-82e2-58467573aa49", + "id": "01a10698-ce83-7b86-8cb9-bdd86e73a9f7", "authHash": "L6Xaql10IfRLNhqvocuWfNcSujwiDqQtcTSGYs62G3Y=", - "publicKey": "ai0nyF7cv50Bp326WTKOZiZlr4FXLOyt+Edim+JBUhw=", - "encryptedPrivateKey": "wKnQe+biABVwMDgj/rdB/2FuYwCyQ7yPbNzWeZ2klI8dskxmf2R6GoXJ4hiiYIeSlbT/BXkPrWLwQtMs1aSwOZXiOMjyG3TG" + "publicKey": "llQrTFrL3ZOvhxeEaxxp0BWCxTKH8HCWywqqhaHGFBM=", + "encryptedPrivateKey": "zTW0ak3a2s1ZI1pCHNoau+RDRiXkH8jskCHSCb6vqQtbUrBzL4j/Pec/W8TwVpYVn1sdnsUq7h3aJPHWoMuqnH5tXuHl4G5H" }, "manager": { "email": "demo-manager-de@example.com", "name": "Michael Bauer", - "id": "01a0890f-d6b1-75aa-96f8-5b5876699618", + "id": "01a10698-d069-7615-ab39-8df88314fb66", "authHash": "LtQoK4+Ush4p+I0e/VCFpwWfejquAO/R77NSHDXW1N8=", - "publicKey": "OLMwT3UktckNS8sURTae5uz8d9AUirleGO+B8TyjhBI=", - "encryptedPrivateKey": "M4zJstbcABtxMwxpjKAg7hCJ+6+8H6SVn5OODHBdsgBu9EaFGbbdbSGlcasBZvW3036PFchkYserQqOA5xSGUsjeQwTn8RgZ" + "publicKey": "I9gKTOdONxMQow+miUYUZ1Sv1FiSws+q80ougbJ6g3s=", + "encryptedPrivateKey": "BXGH7t4N6S7IjDpFji81k9sobFoL2CKLBhSajkmX6lQRICwr+i8T3N+oD+Bqe3St4arQS/FlekePEgkzHHCx1/sdyAaZKPRb" }, - "goalUuid": "d7fff4b2-4107-4868-9c8e-eba824d1135b", + "goalUuid": "d8fc4f79-7b57-4460-a230-c6a20ecc5d7d", "goalTitle": "Ein teamübergreifendes Projekt von Anfang bis Ende leiten", "goalDescription": "Die Planung und Umsetzung eines Projekts verantworten, das mindestens zwei Teams umfasst.", "goalTargetDateOffsetMonths": 4, @@ -296,44 +320,50 @@ { "archived": true, "missed": false, - "employeeSealedKey": "whv4/31Xt6FlSsou1DUWhovCoedzPxPmPg+9E4LSPSUZfg/pdOGk4xeEYvzv/1odQ+/LCTlGs3N1+4i3e2LtLg4dLH2cUfli4+dLQH87ZLU=", - "managerSealedKey": "PD9R8a8Gv87E6G3D6oB6AllOl9n5KmlMOr/jiTBNOC2jml+Pdz2XVLh2p53jD0fv/Q452bJs7TwCIK0O4/9kHP6nK7fv3djckeg/fShvYPY=", - "employeeBlob": "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", - "managerBlob": "Codn5vFaX9FWEPPDJctxTlQ6a/KmFxd8zKyaONtGP5f5X3NmcdUO46EYjhQ55M1qniGGyQOAnYv90Z1E5uYuRJPEgY1pVMqRuKgpzXt5yTC+BsRLYnoTLlTA3iZJm2D/coSmWraERMvZiZK9x9LxH1spxtwHt/4m+/6SvQNANzIGsRSsK46VviMO2RvdCM91bpNWg9dxgEKLbLAQpUxoPdrjo79J/2fTOl9AB8RQJaHhJPLatJWqwTkESGKskNm2ADp4pGSo4cUpgcoM/hlzE3xcgGgTDNF57i/huuNgt/gutNdsOVgSDRPz1Ui8H+Oxloz6pH3GF5oekMGe0OMbTDUix7Wre1MGDHQT3lbI5N2Fx9ntG6pTLzz/lV7fFrsIYKVXwqKfeyB2nqWM6iD9OiUpNvSbKaM29jBnjeiiJ/u2e3XsTynqhrkxbEZt/f0IF03qC2qy5lJDaqssUVba+VNnDxC5zhfEzOf4h0JOBKdERrd5PlIM1rnKEEMYqFoopCLZ/VfJdA0AdZeCx9mOBYSS4ckD32AFDDvQeQbVgO4fJrnIv4coz8nevnYAiRFg/gjn+kHXYgLTYTVssRymvQBn/yj/jdM5FpP7t0iGwkl15/H/UFFNKLHLvqBEXlMk9WPH2EmJAQa7z2uH73JF7yWoU1KDyAwhLlMAycgypdezSSMiDPjCqxgvrIoAX5t4rGH2s0FwrEUCmpDWVBVmQmCE8puCOuRiBDxa/adUkLPBt2cXQ8USkum1mDNYPTVGmcygfnkc8wj36HAgaAYppg3PjumkiJQ9XIUUOMVmcy8l5CXJnE/qRY4MjmBdi986+hdXF9CdZL7ME+7WcW1A97GzjX0Ny/BglpTf0iJAIJDOi4rQHhjrrrdtbXZmk9teAB5ot/JgnvbfgctX5enZk0TI0cuyL1GvsFiUHLWYyCMy2fXjkq2q/z1HqqEbUhyprGQP0VUaXOfVG/mm0nHT9uODDAV57QUuihDNX2TXQmU1tqoiuzr8xmhByZsVuJrQMgKdP8AUBodhe5HxZiBV8JDT6n27Xjt03xMYn9ZWUIGX/0xKXoJExeDdmHfYk4YU2s5348wliFdeUiFjSgtit20Ajdtsr1yiByM3dH3OdQtc3B72jt+fuAF2UJYAfKVWyP0esDljow8QIQebkN7aIAkMIK5Qh0K4dd+qCxDdIjsZCnpIgU7jthAwzYBphO50pTNtq0FkBkArCtpe1t0A9Kr9+1QKQznw9ePA0XWaaGYrttHXJ799kLMiYKyFswfcySOU6qMJ9/3lLZxhJs53GJeUAAaygez9lMPOOdV31fulHEbRzcKOEsYLAxBVzm5KXRBqoy4bjRvAXnxMOPR9p3RDmkPr03QoFBCQYNLFahNxqy/r3dJGiZ2RWLWpjKTJ+KgBlXViil2HIhxlUcKOJuCXyuJGygoyg+ZTVnVQkOAFnNO0Lm94pSh6L8INoLbGB/KT60mwMfW9GG3KqK7nVGn6kY0C7hd//xslOZjHX/ZljsIFNUlF3Hs0HCOfryQaxXSr4t/lG2s+wYkMQ3mBI4UHbJste3dD91/VPSKOt+fXyJjgJnLrK332vcqV/dApv12E9z1gPN62vh2r", - "commentsBlob": "MsiDXLWpcRVZWbDwOz/FphSXn4YmiH8c9B5HkHa6nH2xNg9Xl7VZKpbRhZizhzbOyt+GP+BKYBA9SQNqwbk1bo/96Nl7Lj9QswsZTkokSwffZhh/iZWiECn4z6BX98Y2YjuUizZneDffIv6rU44NDlDZ3v4XUmhrdx5I0LOjwKCIfMlPpSuU+y/iCQs/wI7AdagywJjxqz3m/hi2Thvvb1oEzMrTl5UEg7uy1LGAUcQHqbQ5m6WVrEktUyk0U5FgrTrkWlXmXQZxHZBMotk9ySRscvwpU+LRm0ImatsvQuHoDa3NO2jSB9kr7XXF3wAdFR8bqeEnXi1gyXvJ1a/yIp9J5SOhLNNJymaM2CKotVVr9eSDIFP6DwcnNZCoMVcpwBf3nzmbNsZIEAKR1XN3q+lw/UbAfuYovcfhk0JECBv7h9m50RJeVElZR7A5Bjno7hQfaJBTPh4uAvSYfU4DIgJVSbY0WX1o1vDBkEvRf3U=", + "employeeSealedKey": "y4QcgtF7DnhWAKol+/9yJDAEOXHl1Yx8pgjbzBnSqDKMgzTtktwzr5fKQU6KFIXxtVbNMtXD5AcGmlaGUrIVe6lFSGLj5x2sDSEoK82wp58=", + "managerSealedKey": "dqjLVw56W2JinbG/hJKwXc4aEjkBJoIVIrZkJFJfjRqY9r1IMMg0xupXTsyOQ8UY8hPqKVu0xdQ3ooLq/5UAp8xzCM4RsQUjiBYGQHpRZqM=", + "employeeBlob": "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", + "managerBlob": "MsNpXOqgi2BASnCMAZcBNCNRnG0KyZj5uZxqMElmoJ4xlpp5ehj6jBuCwQ7LKDw67CW4CWZt0rZG9sYncNmaO6lpqQkA/m5E+xSYTEYeufwXRCOHWfz+PK2FMxxHQ0IfMsVQcNj9vAHPaDeFGPV9QrtRc3ZIYrn4nCCq/W9pMkFgcHpaRSU7HoIh99X5pgrykkIJ4EIKTg8awwz6/fuatnHmRbUUo/qgD9yEOx5WOvXTii9HuuiGimnHTIbwd1RvygU3z4mueOUoaeAuNr5hIpQ1XTk4TpgmOtttzV2EP8JA7wrfyl2+mD2H62+Vmj5qIjYwytYBv1ltMB6FPdCAQYJqsy3EhGUTD4tyoLYRxjOqHNbgJzO9RVtgl3L2vIoP3BrvxrL6/MQHscbRywnhHjh/KKV0wtl5jq3ZaW+80G+6TBUuyaxc0YqGBFfpKSG1EQWQafbu0ZycqeytAzfwtjSyPsAshTXxtjtK4zFca5cCf8XadxROpAZI59Wu4x8gxkjdL7pWOAQhTmfXxKF3DlUf8q018DyNODALCECEIdi5D9su2ZKYCBPLBzTjyAGT/auHn37F/HmD1UxQ8DoHgWZKbmRfuPruc1RcYqzS/qP92zMyPLIqwrl8+w7VDz5KQcdVFBKsQ7gsaZBf1AvFk4yf7hDctgWv90wpHTc0Uh4jqsmz2/GbetpRKfr9Q84cv95KPGREL/1881Efh62f0x+OGOysI4BxYCqiPsmdZnasTWeKnW7XUYdqABNHh1/hMUzPWfeN0ZpeAenVm3caiP+BMo7AUG4iIPfiqcgOlxvfR2n0Kek8vgOyOjm8FAP1QU9PJ8t0aAVgy5QEH3/ykqvIEvcc3HVpxNDNICp+Ja1hRWUGl/7lwok5NFNfCLQmOciDS7X4GTy3WzdvjrKa6Gxdj//HBxeS3wnLTAl8w0X8/poEWV0dJUtlvjC4YnyBZ01u24wAkecIfS+0nQl1VPhmoEYnqUZdbP8CdwC+ZykIUinfWd3/wYz5CllJ7QFvyH786GENyJ0bcDmZfDPJiUgztWNWGg0bPtsjuDGxtg2sfqoQHzFVEOf2CMnQJsHD0oXZyWqjGn4f8hoJib2ydDASIAPIaKgBKWLWJwZ+XB3OgWP9KvMj6l4XthJnF+IySGegDoiWNFPjKBRnoap/thhaUNXNzwngmY1ToQA9vS0NsWI5xB2LV7Q+gNo2I8rYouvdDDfMoKrKLev1/S0TdnxY02HIU4d5tzlozilXWJ86hSvnWWs+/OPtkapP30rLMl6WJTE/E6sAeR3KeE4McZ4keBXT3sXGSroF0GQ=", + "commentsBlob": "qrJiMMrjgMAQoo782NBXb3xhO6dyPO4Ev0Zo70DI8gjnp5H44zd9GZFZEUoYoUi9CbY0mX32Z+t0hPJDKIzf8bRxsCM8KKe64tEggkGhjLU00EUgTC3FWUbDlU/w7EyPKzW8WuTxkVQ64SBDYtlo2gTr+3Gj/dB6zpzIskTBiMtEO7XDmzYarp/4XkPaW1iJbjf1nyQ05jLxQrN8kfM+SU96ZBiXJSbjsIg2/zECpM9Ttu3g3PRIKXkcqTMrZqETaR5Q8OXtobJA5a2RUdud8ySL3DL5olosHzLT5Ziuag4O7OPtcRvDAUk5kqlg/i0eMKxo6lZPkVkqqeZr8pC0VQVwrngAR01yxUGAxkOpcPn4vStivEYzt1E4T31V78mpxOngPNKkrMWythJAaxWty+kZMl1IgN9zk8vCRzfhZoLePLeLUBD6C7FCO9ymkjDpwhadO1nf+LzdC4ffC26T2BMXU5khVLmxDNFgKgYq2Us=", "commentsVersion": 1, - "outcomesBlob": "ULgMx4xodDrsby/9JPO7+vsz5rrD7BN31O30AmHuTEfh9M3RM6XSAVTLrTZyxkTApWEHMdffqFJ0hhVtymdIpS9TqfWDKn5/c4NVgGyesy5DiJshXk2m+apivXgej9RvphNn6HIbCBLwZEMNsuUFp4C06y5YZjBP2oSMcR+jrOqy8lDm+32FrXE7F8A3EWIOeO7yZSWIFj0XLsKigNV3/5ZzBhoXEA5hllMKen/XBUj5Ddod2SnbgFVhvQbUgUGLlyTDQda03kLxvrABrgWrbTRN8Bp0js1Bd3jHbjqqs0Of2dL/sfUI8L2WCaqsSKQySNUrXB+bj0RIqpTnAPI88c1XMjYMbnV6XQ/H5TBTGiEk7qen8CBLRm60TSkqnMST/aE96x8/xgdj1Z/Z1ixaOadn2gVYuc2piWAfqRrS69PlteuRQZO+DUvyDKzbbSxdHasrfr1/dyVy", + "outcomesBlob": "ow798WzOnveqNMpxo9MC94qlx63/nheOvak0xkn/33FJ7nO7mIx1YkBdYwmISXdGegwUaoD4WgQdzihFFAQ1npLhsv3xzPn3AxwdTteXel+Y7Bg5BTehYTCdr37obcfhJXPFYP7DEWcDSR0aQ+PqIYi9YGvborcax4ntfOXRwHLRTAd/NIz1zH3bL47j4hQfp5go1KWV9qHitKx8qupSQjLg2AzzDTYR5WoPu0UTFwlJ0xFm1lNY20LKLC43RIv10ut5YC+A4Nyp1IfNlfu4d9AzQY6o/y7CQRc0UD+GnNX9jyBxIXLYiQqhkd5/uyWuqIHOAxXN+qlS/Vco+vIM1gmIobO4r0/YoZLrMy2efVPcEYH2BvTSbI5TdSDGuqjpVyI5A8ULNoHLIjDs3/dkBpkkNpYwUaKk6E/Kw2+47kI2hGCzW2ugfn/58ZuLFwz1Cbtj4r36keWA", "outcomesVersion": 1, - "goalCheckpointsBlob": "Ih16WdvI1QYG6hCCwaE8IhnHNEHcQljXm7jAfDl7GvwsatSxmYBIO/22AFfPnIHa6GaGX4JbZV1/pzaXjBCSbGaWlSQuIte2aaquz9oQL1qDFqpXdsaafKzCVwUNjJ2TVTlbbCkFhF5qc37Q8iyBR98p2W0Tnkt2XqA3AJeTUHyHAlaerdjhotPgY++xrB0LSRlPOdbbP5p0aS4BTmMqU1epo89tJXcjEFDmsXSjZdL6Ncchv88hmXpEbiqXCd8hQD8jJpb1gz7MRtnjp5puX8+HGudkiWuM24NVWWR8pNU4P4QhiavxJF0FIMvmbxWyVphu/YCehAqun1A+P0J873vPCkUaCJ1ASitTqxPKrZ5YQytqZlJlwzf6oBMwWGpLvwkc3cA/6cakzjKLvQRS71cwegEbAOn0+/FXRZz3H2vVduka8B0jIurb6BQ5+DvPIp0nxPDH3CPFAE684uwn/nqJj4BCSHRxvhH7ue2Zhpd0Q78xGS4+eBP5QVTkgHUB", - "goalCheckpointsVersion": 1 + "goalCheckpointsBlob": "3HmK8wv9oJ1C6zYgrff9cXR4vULKzK3tX38GYMRhnBQIjTJmuxV2ImW3kQp+5NimPAqlykOcqj6ZoPCHuzbnbMaB8TPgFEt++Z46k4s8XArIVuARRFjbOJJZjLy+C0UjiyC3XoRylLJUiv3DelpsiRJDqnIrKAdrargCS/rpa+gWSh3Vj299xi0qaM4xt/e+3KsqQhvdgYK48oeyiqVEtDmnN7XXSe5I1xzmPJUCtTRH7UfHviVnAblW2wjAy9NSn6yfKiPYN1t1pd/es+6MG97nBcbHQQGgZ2/RUPdxVqJEUU4EB+GLlke9VGqKmDFR4X0qqQeB193AbNc+OniW5R8TjO6SliLlEb3OF8NgEIFGfqtdmhNON1z+ElEc4O1+PXrVxbHZVqTfbmAo4VikE50dkww2Tr0+ZlH0UfgXHUuQQsNGiK9P9xmIxCIT4kA2U4WtO9W/aVtDF996vDevVvi2AAQzJvtcgbio9YYQjBHGsBUU5CnJJmPWXoPag2g6", + "goalCheckpointsVersion": 1, + "topicsBlob": "8H1riIDGED7lDHbslyBlkXS8JabpnHC2BADvgPnfS/8T3hd+FZUpo47kuL4gtM8q8BiuUeOSifeaoJPyUPqsgk517jld9yPPuV/z92VEED9Vohu5rnNBP5gKiNI74Om0dxStC0o22Fy5/deyso1/CvZOsEeMC9Y/QxsuIJO68NjzUHZnQ9Oj/vTmkoxC9qEAnP2P2kYMswD7cHYLfjxXJ8j/d+sw/D09FtepcCD0eLunQKxWxHXIfHIP9HvT3AKCG1+iLl+gKT2dwEuy1PIqw5U1Kxf1P0T07yODOXgF86f/R+QFHZLYUS3SuTUjDHsUpI/YNHUM9RhENiw+nyNlyq1cIJQvAmqbtr+iiOWTMXbtGwjRuBuGK1i+0cB1HWkvIB6XddU0BXca9B9N3jQbc2UabeMY04OkyhUC+GPDoQKTagmbWmAXTsW/BSKFwdO+yv0Uo7+eLghyALAh7RzparVXTQGxDLN71EDdlt2QX5AE3ple+kKxgZaKWIG/YnODsFCx4Uaefe7ENwpDpDBWbaSJguSOxbca1623gPP1GDd3MHxpALyABXqRzx5GN928MajHXnC43GBYEejHrzVDkppqneXhCitbRpbbGyxKQo5a612pBL9554NT3J2XUVnj9p+yIIFC8I8DJhC8FQsQjZ962Pqlx4CDuGFjre4QwTJtYS2bH1lHpvJrIz8CTA/zZbKatRtmZUTsd/oERU6mx7U6iMp0PRhXQJeZDmCEqe3gwVkE51fn6AuBlBuY5n1l6rLTS8CUSM6aqAS7lD4QHldt1qc9HUbq0YEsxxHQGww+zXtXiDkoejdOrRLiKFtm0zRz666R", + "topicsVersion": 4 }, { "archived": true, "missed": false, - "employeeSealedKey": "matL2XfHxrYoJ+1tbDKjaXCY3WR0pJUL2ztwpQbwvGVVEnS3jEEkM/XG2d9EHZsR031jG0kpnsL5oDIluK+Priu79mXWfSrKl9PYAuCC0+8=", - "managerSealedKey": "Q4Peb3XXi3m8ft+HV2p1HM19StbBhiiYs/oWFjMoZxR3ZrnvAzivaidlLmzFhqn1OLwCAZEhAQY62LHHr5Zd+TCpKw+T9vtIuckMVT0lp6c=", - "employeeBlob": "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", - "managerBlob": "fP+Mz0pSbtV/lZoPxWkljiYP5xYMX/SwboWTfoAFjjS7zcqwuGsLKnayAt9r00sdD3j8SVrnqBNQbqSdflFI9b8P60OeMIn4SlSwcEB7NGe0EUoFvpARIVXA686K4G3SFw9C1MhVdGa+AXcDd8KXsCXmZxpq+p8x520tVE9sQZbSIJs2trGKDfAIYIaEcCHPzs1gUZplR7+wicwdJID9lraQwJ0hVANLsiGzzgwNwnDZ7NfQXoBv1vlZXbSzvtieNLZvz3n9RUKLmqLDPdxDlGujLND5ncUCx0RkiX5SfMnszb2IYkCWJuA+zsIQX6rHqJUdFqueE03oaikZxHWownHONQFwgGmwvJs7Zuy7+Mu42qtfZNlQNwYGhCorcEhHyOm3Rnr3dYwVmEOoqCiZpy7vNekz4akeZtEDKstCuUxnUoAcfGg8K+zzYVfpUW5xolot8C9SIdkfCjZDjex9wzQeSQ49UNUniSmzIGv3052UWMrTARP/N/VjUuMqkC6cqY16KI9KuyzfpQVWFvZU+s2C1GaFe39hORDMifoYJFFytAzN02y8CycA+BHyO0ogafnuoC1n/bwqoybYtCEHJ6DVZkga+8DXZUDnLU9X8RDD2s9B4zPYQLibwLzTYuU0Ywt9e7urnOQo3wSOmFDscyc6j/me9+tA5qgt7VTWhlx6Ry8N3pywlJsGqDDw8ay3/p1bVcBJE75AtpTNewZQmOKTUU7/DpLCx9mMQAqxcKeSV/UCAAmtfVxQvEN0Dx875nBlC4tAfi0LOJpm8ToM7Nq4RwcmfzNvpzI/RBSi8rjhGXmu+MSmkTBTTizNR8i8dcBJxR9908OEs1aHw9ZziqvV6d+iP9sOWuyq2I3FNCKP5EGTnhhSWk6f83oTPd3rz5XNRb1G77vfuFmg5RuzkULl9xIOh6QT+2e9FmKe5ksEYWRNmCwjSi8i8bBuY9HqBMjXyQ0gGmPE8+82zbm5ZRwtE+Dl1JSVN2Tk0QjnrObmpYpV5akkL1OlFz3BXLKJI8ke23TEzpDT+9VhK5d1fJhfqgcL0bUFK1wePXdVKMhq7SVifR+PPj4AWx3TnxfoYxeZVPQLH/MQsz0VusBw42ND5e4l3jPC1oZo82xudFvpoxgzJIgMv32pyZVjyPhyH6KcZ5L+Y3Ur7PF3qfQhEiJjFZjhnDg2h7MgonKW8QGIViNWjK4P5YbTItdGFc5aPRdeHUmMXLV2unGocjeiTHKjGvJIDIvN5/DKfHvrGMPYEybBHnT8kRhtajz9lSXaTQBKUAaiL779gDyvl5zC65jpI1UEJ0QnTvAmrpyTd3ux1AI+KzZx9e7OjfYP6Us/ycQARyQwlnB1oKLC0qEqU2Kx1iaotiLKdr5Ymi5lDtw9mfiIpVEiIxoqVuZcKu5JyhYGd7GUTA7B8nuV1eg+42L9XJf1vEajICYU4yhgnm23UpJ9yp9bT5VGqOmIntDPoXVWRZKPMFE7P9CsCxlHB7jQNj5ef2oD0pP6oZfxAuDQrd56AJrJkt5I9r46LaMjSfatiTUatL/0aZX3SDu2wVCPCjjXJez96Xqa5lM2tgSOzeYLjkf8ADIWPTKWaJecF4RDNBIrp+hPoEP7ZQbEEKY5ZognU/BvVcEjOo4Nz9jfqvK2mhPI7c2ZqnkJw7gAOwJ/cjRNm08LSYJ/LTCosyA+GTMYFDErt++Yy/HgsK/WJKau95Z4F6p6WHkOc2Zng1imZhHU0OxN081DgufApTjadXpiGvG2Wd4uk+JpOxv6A7LaXistzgQ4rcjcP0ksMFwMG6Of6sprcu1Bn5+gSIOpllBAqULJOe4pUaE1bQDgOCY41u3N3DxanS80V3pGM5JLTUNRwrYowjg+jKfG+RxHsTqIprvPZUvMdxYtUisdCOI+eNCL/YqJtnSv8j2N3UDWDAu3Pt3Me66x5pGaK8wFFnPsgzWvLuqEo45WGhwNY8j/RKK/oFu88Vw/Dn1zXYVPKFKzAau6h0SC0xEqKKKsPKHqFW1/w7cRbJOdFuyeBL9d+wUQtDbTWmQ=", - "commentsBlob": "8jxNKYpYnMp9kLUMxAGGAR/I8yy6z4QnIlzrE1QAdJnqIdSIkfNG/yr5HumdFB0JU3OtZ5LCToodoJwos6BAPFZqwQpnJkGz4BKHz0Ws417A7qfCr03dyPWbSmdMgCONxET7ryIRi+m2Wo+9YZ9AbKXlqbenDGNCAD2YyDzErFozbL8Lnxtqs46lEeLnjZa6/uIhTNQ/W/RePu6YOihcatDtlC6v1scxagqcHgzuerNRrQPhRKIjMrqemwDCvprYrg25K04SGvsgw/ta3U6m0Z5s9WeulOQt2U+LbOCkBo3L8GzV9V1baDuQrZJJsCzcToND8up2md9i0yIKcruNApOS/m25AokgmLmVD6+nMNJOVt6sYVeZmtBg4SRFRYmOC5zjbC0hw7yx", + "employeeSealedKey": "kWzn0zT/9vWhtUCpihDGNVUuipAzgE2oZi8pi9MJCEr7j0FJ4P/XU9HNtv/qPmUzkosSC3EJC/WaSwbScusBvfmve4wTo+HfdXsxsrSO2wY=", + "managerSealedKey": "ZOrY8FUjAgiXNamEmoKIm06X1AgmxrD9QMDYdqpAOA85wXev80MnwDGd59/imUi/5IE7c+q4J4MP5kPodtW0Kic0WWGsMx4/oasdwRSfGFY=", + "employeeBlob": "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", + "managerBlob": "SjP1JjXDkn0p9Dnd+0mcMyTIBFRaPIiF/RXVkqKsesh/2rnhYv86FfUJbhYwtmKKoVJfVsin7d439PyhIxfQasSSa9nWEaMgjz6IkgbqpuRJEWXcOXd1rANdfp9xSI9NdruDOpVcrvvn0GOPhXK1zEazP3/OTEtTR0uhsfPP7uA1WIq+O6QqAVOD44tn4AyYlufqRsBmxGAetcuCiT9DAxgHbr8xIputLhNXsBaNlkGQ6CiPNiBHb3OSPW5FaYbzcjPNiElFW2GpksdvGb9BaWFDEgBKKnwAJdOsXuyzPoYefyNxRASGW2nMTyyNpyXF9pTugEnPg/1YGwuG2vA/ZO/9Us/YrXDeteVocnsk6CxLBRALu2qjIIIupzMoy+g2PjcCGjhFVYQfBtZjxRLhr9JdDMV9E1gWFznI7GlW7bwccz94upDKo/+dXSkZi0SwS4pn0li4uGFvJh2uoD7Zmjgizesx3tLLDvPxH7AmhMV2oP8GESqUqfwDlvL5NwSiBJEjufKvJAEiD+1FtY5zMG2CqYbl5TWrh/lBX7v7JFzZzE0Upoje72kEacrNpNM6CHdsLBZt8fuMHt6p8T0QbyYozak5Q+Ovd4xGa2Kna7eIhEuicxKknJGEoaUFCe7m4NJKigbQerU/17uHePWlvLTJK9k1C6sqvoTnqj23qiNmCsAGhVqThE6tz3Jzp5O41HWrEGrEf8P4Oq9pmya3uHA8cGPiq2SoZdbbZICYC+HUjbQaGltGu+uHbBF7MKBtvLo4rVoAaP121Xa4q3INZb3E3qC3tg3M0w6wCx2/NMP5D6JmejEXp+eG/LNAaMiqGg4C9vYE2QlPEMTmd3hjcvLu1gqHvzcVJ4W+G5gQd4cdX8thTAmF7YwNA+6Dy15DHCKiPZUx52h4k7j1RiG78eDwn/xKhgObn3xXr5g/Qd81LCiWnsfmSKCVcC50rlMrXHiCqJ2uZsciQ7y0tAxi6HDDz9k6BVpHrwEN6Ic6LCAPsIBOLAcUuBT6esLdbTLRuy1OBcinNsH9FFLDvRtAqjXd0QX9GItSFiAtvOYWD24SH1jpGWK3oneRmVBBMSB0RendRGxzMYC3j74elDjhgGRjx5kotT2y3yF1uHWwyE4NVKrBKzq66bHueeKT50DO4jBoMRseDHUTYAYHthnPIR41SX9KkOv8am3kClkYJ1oCwCVKr4R5dre9SXpPYbfBg5vupsbLFRyFri+lInJOuS+vq7tf7NNfLlE9tGCK1ktkLg956RKOx90KYR/fY6EvPjnLkhx8PAhGV8bKu92+Sb/Dj0/Ys/vvj7y165IPfUrXBvQLx7R5sNkxoGsdpWKB+eg9cd5T9JwWIZznA+GsquZA5CUBggs8sv0QrkSyANbmkI8NmzBTp4dIB9LeB8hOp+mPhaC5R8sN7UGEIjOBzEB7KIvTlYSbVoas9PPWTqMH2AvTggRCMEhyzAbUXQiyu6dQPTdAdDe6ePDqwVgV/RatmWHtbL2Fo7sZQkFIv/6gOftOa7T+hWYGMPR/Oph/UsB5yeudu+lNGchpShvE+2oOTKVSHLYyptm7XFqOUkIP937eSYeH/4WoljPW8RrDpLS0iJPrJij+2dsAsGGd20WcH3K4oI066NHXylmFj30phBPiTud989505uShRPJs1dgBSm8Uj6oH49joV7uZYHF6wiDhY1Xib7x7dtokJ9C3LY3cRZvn6encHQ==", + "commentsBlob": "fMQ8TbNBn8I7ejaW0cpXVQUD7Zztguf4tS7QXbQdRntdN5Z9LxM0gzEnSwoW+Xne9UBX4z9jz9QZpJFYrGot5+2xS9lt0t+IWP4S9HEksWI/5CW4tJDOyrSZpOj9QjuVXWtYM+naNn8bPutVVrFLA3iYpbdJXiM+N5V1swW6M0E/iA/W2CKmqc8ESXxfomH8EvKpbmodp3A5ZMxSE4zJUqeFRvyXAi4Pf35VJvpJ/YxVopOS8Hm7C4GlBCYBrLOI4MpI8zTIFKdnBcnrvMdbn6VK9kRPuEY7qVRvpLJPd2KX1iNv7MU04EiuRR6+vk/YXYh7QXz6S7OKK0w+sRfM/eZLabv39p/5CHLy5W4O6P4BICygDmDcw6qexbgbKU6uJl85ZhOKC1Fc", "commentsVersion": 1, - "outcomesBlob": "lb+688JN0ux1sl7W3w5jbRXVz5IVUAuMdB85kgftidSGxKZsSHXiMEw0ewRpQh64LtoNSyF0wHKGyErDcmvo93vJpfcOx8XaMCbzz348YrFZmVrmjzaDgdVbilcgXqt9MEYnVNpMo79fVcKH9Cq2rd5w3Jt7H/VFlKtdRaGCdu2fEd5ZZir7LarcVCzeQWMoZ6RBasAhYMAL5yyDUlv9iD4dfEOXe3CuFSjEQhF8K/VO3yDTAKZyA/c3NLRdDSDNbvENGiSmOuE7wwQU2CO8EGPgd8YhW+mgB14LBtQ53ZUAe9W/zdL8DsZqfdezD3U6QHVBEZ7D1WLgHO4Khxf04f2SfVq2qj2NezvgSFQMMu1x1YRDaUVVRJQpmwj4q1CX6JS5pI9xSMwUhYgceT0LIgF1lWtpIG5NB2zv4XlNo/M6bMi+Mt1MbuvVHRLuTFiG7jf3ONnzu8h5Vg8QUYOFOVVa6+4Jhimhbb5vjWPXoBgkUd0e5lNEAkaESq9NOSDiMibsHTzfupHZh7mu5+1MDfwaOMawrBqZFrJKzR5pBnRsppelLIy4P3VF1hLnvJyV42Rv+JTHie16gSjOaQB2vD32oSnGVBi4TvzhA40xd0YQcy9dCWoPa/RzF3QtHujsh2Wevz+2uvvaV2soBZcVfSC1hnwlcMus+QPHgRncbdj2r94OiAswzKl8nc/qQCcqRRnI0oWF/t6YGDQ4EOWTexCJx4dv0uXuq6D0p6/jhZ9aZwRex3CY2FeqpL0mPouQpdiyDptoCj0kQThADLtz7zCGmj6ck5bx3gmKwlOaEc5tcRwdqLAdUAFm", + "outcomesBlob": "pyxw29nKPhbNWjIdx6f9ztYhr6GPbsx3xRDQz5HwmSa6DITpNziAEq1dvCUjs/5rF6GHPsBlvRJ9gP3VwR0l13FGMgmplU613c3PwHsjdRjwoK6+YBYcgjOPMWmOIdgqfDqJ6SpQEpLfE2ezLmoYju6+dRcWvMskRWrT1i8O0bdoKnUEXAP+da1SH3Oejjeb1oL+avqZq3quo0Sa3+G0DVHpw9XGGlmFXG3cpbnjf+dytNs99LSl+bYlEkMkfzQOXRAem57CYPQRNjcpROHnQ1XBMUqqgBt+RPyzs08P99ECHMeUgqfYbzMoSv2B01EjOWcpsDrmzplCGPVKaHKKSgj2MPznpChouK/QjiPFFXLUP8pPfzbSFcrzlHxaKm7BEo42eMVv2++Pgj6wqY4Nj5IP/D+2he99DfIfHuRb7v296CmprNx5IgYnROImReWrMbjmhFOT5yIeRuSgnYzTk0FM+zdfrgelRya2TWUP0h1/BaZBYaYccqDMm48CHHce5LRhcW6tgASlyHwzh8DGKKFG2heTz1DyfWOBYDzT72OuAsSiXQbysaAV7SpEf2Cc2bPQHx5mInpDBF/2rSLovcesYZPtj1LyooRsWvnT4hXelSZa7DKPBB8F+PKDGzFWwWPP+WppQzy2ZQ0HEgxG2JHcPG+/HwiDCmAyo1dVe9XItKowCTvHrttJ0jfQNPODSJMwZoUGzbwiyj2l+iG6L1dUZFQe2XYtcN/S4I3DX/9b+1tJ2B0a6zdQmf/fqzqvK4Ot2XdNw28HacLLJMDlYdwa2Pi5KzJO4zI1LAceTHkH3DLmBgrxTvYM", "outcomesVersion": 2, - "goalCheckpointsBlob": "1Cq/6hG7UzowGJMFBO38SzmmfZi0nywL8qrodZb6/3zTAsLPXsef2r2VgJos2wK0HwyYjuKiuOE+90PbXMh+Kj3ywisqOUIoMoBFjGvlSDT9tL9lSV8Edbcm0SaQ0dPFjqtfozeLtSuXVXSn8/emtkO+P3I3m3ZMhA1NwZtSShwPeFlsu5CnJo8cv4BHwW5WpsDK1nxTBTdvoT6AHqxKh+lxgay8X2dArRklroaccHyKZluABP3c5EblodL/WX1+50l7e1xn7mxzdYmwEANljfVDkwsAyEUuH+S6PtSAPgjBKqs89tWX9eFmV4Y56YJwKDda827BNJb4ycuZ+ypkBiDzQKOKCYz6OZ3U0M3vR3YWY/7HVr8V0kmcdNxWJdKhd3AuRGMqib9LwqIWODGIvCyn4DFq6/ryGyqXdwvqVG+CLRgjAp4OyjQkyiabPuxG594dtnABD7ZeyfiH1NWcjQ==", - "goalCheckpointsVersion": 1 + "goalCheckpointsBlob": "wgMi+XKPOSiGSJ2me8i+WPqMy4rEWlQ66lcDN97RL7cRXjFLsVHGiy6xZu6kTYfUKLkJG7gRtQFrT2WuhARzJVLsh6QUlrgCjOaYXVDxzZ/2CPe8zYopFXslbU8TB/fs8Gx0hycIVIrSxYuQAU9I5M86u9mgiJRzqPl0AkRZskb/iO/nyVw+jIK+2sYuiSv49v+fLUxTZ+8qxPBlYOBTtTSxLYsJyOUiWmK7cMcjCRSDLh9IjfIpGFYUUSI5EIQhU06D0YvtLFFylvvFQh6Z2SZFwNnCGPKwd5erVaJrj6eU2DN8gg3jmZrKf7BTFfufp6oZ8f/bXah9jdSjaCNuOP7my4O/3RySNWynT3elvAhd0Cn0KZGKiOk4Gp04U2X1OSsMW8AVVFKvdO73f+j/qv0qUX3Dm3U6LYff6uNebOM+Khg4UYTYjlp4k0T9oOBvx+m0oJpW1YLMXuZOqeUftQ==", + "goalCheckpointsVersion": 1, + "topicsBlob": "9c9k1r7SPFuoZ/E4ckkkUDC5Odvq2K7A4bHDk3fIF+UP06JBkB1JfTeylNGltOHQw1tyVsxCyy3u871ZxJD8mfvCHUKwXK570Z4swxEXueYARr1iXjV5Y1whJaAkLlw/sOjA+9w1qDzLO3IFCqJFqgVD04MkMVwfNaDxjTgrbsGZAys+2SdjlZuq207sLcJ59eAIeY5xK1qwRbMWdKFNDEe/sIpAPT0OxNqRr5qyht4yG5urEU4wUPjXCMVsr2ELGwr5U9hhrveEcVxAnjxRW/wHtEVjOI/nDi6b3u0rQckBhHZl2GqJ5TpLAZx624acUz/O5zRjwJ4GKmxuM20drUi/e9I0YE4GLfYqxp80VwTo+U4btNlsBPUPvhyiV4T08Vb1PtYexqZbVxWfd3XpZ+Jz7f9GecOumFOmRX0FiIo0hOYYHWcKS72zpP4Zzs+tkEUjTz5dDCg2aSMLYOaq4BA960+v5PoJXofeh1OgkluNDXIHz3zz64JtCaZ3+6VLDiV10ieWewdEc5G9Llutdnh48vjPmFDymOgdAC32/fyRIVBo4J1TiT5y3DLEPik5eOxukSzbvh5L6d6XNgljyZ1VA/xeh2AXS5UiOBMXZNyGH6CRj50VDuJtepVOxV9KKUWdJoeZRGaLDVZK4CjNDp3T8PUWKdhU85MyefImC1A1MfEWEniw6Ek0hom+r70AcldUJ/TWo6efHIGMXQlkTQeF72UADAzBV0BNhiFF8IDOfVuVU/699uaDpHIFvc9H+eyo/eSG1pGHPcDVDa5kpGDoqPC9uUfrYnRCOm+nGnaXBPLKSf3xKj1JX2hfl6QmKYNdUk9j3+qK8ahJ804v2v+HmcehR9+jR2dCEGXvNdt8", + "topicsVersion": 3 }, { "archived": false, "missed": false, - "employeeSealedKey": "8NeeiH40c5EETxx3FpzLEfwyLQtnkmC4j+5VuzVFaEab8rhodZiw7KtvmGlcKLLL+vqqa9s8MSUyZU5sYRg4MMqJLXZje72jOZ5maFK/E6U=", - "managerSealedKey": "RjUcAF63ONRETOoy3YMiFETrLEoPKugnyVhcJqsgY1i3RQ5bOQCRLeU7NdnMwMT2KFg/R0QIdi+gmlN8T/3BMwnto6LJhGpc1MB+F/xJF4o=", + "employeeSealedKey": "RZk7qkfAarCWbdvBH5RanyvxDPbR/xrGCTAhxFhYGHv5pS0u3trBdevoDnWeWR5G6KRzxkttUquJqJoOIhiuJu0HOq658Cx4/A+NwJGxNwQ=", + "managerSealedKey": "pWBn3OmDGCxAVl1P8ywI7MmI+pj7S2anq/asQ6rzwjpYrsLSKabftNOykmu4pl3/PMpZlbZtxK+zMMBmh0KCIkGrGqvcMrvkkge4u0KczS8=", "employeeBlob": null, "managerBlob": null, "commentsBlob": null, "commentsVersion": 0, - "outcomesBlob": "mYYd/izAggsr8yDM9GNMRyZQAH9Zba02epUMuGojHVOftheJH979cSIxB1XI7WYIjqrCb03iAaO4ZB2GAeFfeItg9TsF449QsPWYRuI+omhH79UG9BaSae0PIw26W+HnUdkjqpe1yqJhj6u7fMkwdMpPdacWLY1ZmhTaHMiLwXEnwUN1DkButfJO4qiXplom94T2VnrsSzVvXpTx4zB+f0nbrqfJxrd7vmzXzbif6AYFo94Hg9TtLqoVGUqvwHhKS1O/1zdaiD6py6soKhNLu7o9y9E8TCOQAkhLhw28+7R66c1tkgMBwYScNF1SBk+AhsVksDmAIppVaSB0d456DyxLzcpBtkjAiSyCK86wW4AJjH9wd1IK/A7aHHP65gYB+wCp2pdSh1ULYQ4+3ca32uvuRMPdb634UJz5wmlTDjolShRkjieD1iyKdr1/yB9Ikl/u/Ykv", + "outcomesBlob": "qIlbBuE51dvRoUXmjhdceOEszNnq9MAiwBno2BFCsOA39iBnMA5oGREm7wyv289j8U85OKRD4QSlUIKEp4k0tFFZZR03GOFjL1+gT7yQsr32+kS0AhEloNyHu4BwnWuZ2Iv5Yu4cs5K16Ao9nVwyFxo1O+T/x682QnB3SEl0A6y13qZAWos5Ra+VCFMBCH8IhvudKJ9NC4cg0LRWwKGdomebXpT3xJy5BadvWoZjMoHlfzx98A2P2suPko5PjBxJzizdNlTdMS+bRjlK41UJjqxl5e8LmVkow4YdOEKO4wAeSEvsu0xw8Vexq3pREy+9mQkCVW3daCGIRRi+0ym6SsTSLt+RQdhZ24iEhK5lWRXAJGeMus8SNBReBkopMcUIsq0fsZIsm6kfFoPKvKsFa+MxLbmdNDJhuUU70HMsv8T+zKbNGIV1SPz/mdjSlmZjLSAk0HVC", "outcomesVersion": 0, "goalCheckpointsBlob": null, - "goalCheckpointsVersion": 0 + "goalCheckpointsVersion": 0, + "topicsBlob": "M5eodUAvEMuD6udvsgoA8lMgO7BfOnImbt06CMRhjxYmQeiFRhApDbNP5JRK9Op2mqSDwDiRDn5Kuf1PEgFiqv9agDWyrwYGCBv+Jsq9PCiibt6AFW7nLDCdL8yjak2iNf1kp92Spgx+NpKB+W19mHSemUqkbvCJPV8SV8PtYv1YctcFbYalXpLtKKjUjc37Jr96u9gjxNIj49X3oCz667NpG7DvDby/DasjKf7KCa7BYDuC2UoO3uLMs7DZfT9rg/otqjxe8FloKegS9zEbuImWByuPZKKb5Jq6JhRVZfIB7JRrFoQ4HxGtdgHPwNnN9oztsDZcxODe2OXKOge5x1nZsxxnKvHs1C5S2ttinwb3vJDkX76ODnSiLEfEXM6e1p1MQJeO3QAvYapzj5m7V+VQhsCdqWPeLkZkPP7Cb/c+RgtbYbKEvJWCwxaqzDmT8IH4zMKzjA4LGMuAaDzZS1rjxxo=", + "topicsVersion": 0 } ] }, @@ -341,20 +371,20 @@ "employee": { "email": "demo-employee-fr@example.com", "name": "Camille Dubois", - "id": "01a08911-d84b-76c5-8283-b2cd71969f40", + "id": "01a10699-0bff-7c70-bec6-8e75b11099ed", "authHash": "oFTsiOzdRTwtN6XcXr4HEZ09vy/7hDq0zX92gdDy+Hs=", - "publicKey": "ofYQ1rNCDSd/BLhdDdmGFZOldVzBXatzrKTuiiUcTSk=", - "encryptedPrivateKey": "tcUGREGehcWjyv3DzvfF3zOVyfaH8IInbbilQ68I0/Ai7OqjIFmJ/4YXmOSpGMUIxhXRiUHff8BGzcUL9bPC+w0k7NaMDgvC" + "publicKey": "Ci9dezT4Hdq7q4uDloAQP+fU13hhIYx7xZWCd3ljpmw=", + "encryptedPrivateKey": "7p7isBpYcwU3gfJ3aq6AJSh54ieHngF4jr1/8T1C41mMM0pGp0VRi9DU6KrSV6vXduP0Etx1Rqk28HRPZm1SHm5xLUbkgv3s" }, "manager": { "email": "demo-manager-fr@example.com", "name": "Nicolas Lefebvre", - "id": "01a08911-d9e0-7366-a97a-d846bf183bbe", + "id": "01a10699-0df0-7850-836c-c8aeadab7cf0", "authHash": "pU1Us1IvJmeLwgLjh3pXLiCXDN7K9FFKkNn8FJ4AZKc=", - "publicKey": "brC/CS7Bx9Br1MNR+XEFPdKR8D8irjXjG/tBVkCQaS0=", - "encryptedPrivateKey": "qjrN6pcKr05ryv+RzPTaHXNkyBZiDHWPr/jxOX562sHMwwYloQzr43ZWqMiUrScC1Z2vKGpaQNfscHooKByBCz1KB5cA1HWH" + "publicKey": "mzCWveDhNRndtXzKdfiIzx8XqrX76pAxmYP8n9tqJhI=", + "encryptedPrivateKey": "bKH+0UnsYsNlWgrP81MDzrICpHeOSJWOIuTdpg6oEPR/IRqS/y4wuoB89d5E7Wia3hw22pO81w+S73HS3BIXGF5ZCzEsigLt" }, - "goalUuid": "f24c27b3-022b-49db-b6c5-5173d56cb80e", + "goalUuid": "c0198897-6384-44e2-959f-89b00ef8486e", "goalTitle": "Piloter un projet transverse de bout en bout", "goalDescription": "Prendre en charge le cadrage et la livraison d'un projet impliquant au moins deux équipes.", "goalTargetDateOffsetMonths": 4, @@ -363,44 +393,50 @@ { "archived": true, "missed": false, - "employeeSealedKey": "cxfGrBqMyCw/po7Y5CA1ENFI9fgW60LaqD5Qtbqz0XpsQXjvNILuig197S94MmJqDk8zIA+j2xDZN2N/GLccs2rX2XvrweZb3gGrOnVJ4K8=", - "managerSealedKey": "XEm/AtO3XNnRrykMtsI8NecxFtY6HDLH41KzwNAAlzGmXRMMb6IXx1OZNZqJ3uwxOef6qx+B8Na0Pniqo34AbEspjvrqL+B1bCJw+XCvumA=", - "employeeBlob": "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", - "managerBlob": "pLPO+BoVw2j/hx6WLe0BFL7CIJesxFEO5eRu2sSyGGz3oV02vEXm2Wi7E+xUklMmFI5ixL5vIscR2Wo2V08MCpHY9ChwkF/f5Mwb0ymyc79ZtJTtXTa4BGhjkpm/DLI6rj6HbRXpopAxpK5j+ruKRXSQtPG0xcxq/mpf4KXZnh6dEBhdDhJVWZXFoV4hizOEcOKaZbmlcg0BLWNYm9zCl0+knF17Qvx7g5s2DoK8jlorFqG3SRa5GDS5uZ4hV59YSBM+Rk7erE2n1qlJYzS7xfuETzEI0ft5jrHMQmR8ew3I3T6DSQws5cPNImT56kFDXpEjil+HT6n0dNriworWEbW8A5I6tKq372iz/KpSG/nE+vw4CN7l93wmNBEgGUREh3mtSa6UoLkRrDeM4GNDeaqtL/rhnHh+Pmlyb+3jjjiAaf0rLWuzzwz8Ac5BXx4135bld+AqCqe0saOuXVSwnPyKv1oMutEpHaUJIT1naFKv8YBe6BOizERaWMx1Qy1kStu8JbHGItEgmwiB3Yn184xd4k+jTMS7Ccx8e2CO7k8rmGVEIG1tbcEMgzbs8UjD1kIKMJO6fyizhARui6mgd25Uv4XH8mqz3KcMeIUy8SthFGX7uG0JtJfHgPcPkbMd09Z1gnkUrnH3tU/24wBYABnXxzI9u4RimlGUFYWcldoGPQjjvVR134Kek6DTLG+MiSU9fBevovFXTXH7AJs03ZGBsa0n4O/PxrC464xCMLlFMOxRkptJ6oqS+CMwaYNxnAqwkoZv8fFjUlhy0lirosVmmZJhjzKG3gLad7pnsgYBkGBD6LvXlz/U2iKfv2ikeILJ9s9HWrTXL/XlAqTJpqvl2gj1/sSAEHRJjePwwDjOfRwC+lZz7cSSXagfCNvGIsc/IONcrrIapGGKadnE+fl4ARHG2DAz2aeNeAIICkn+HiLO9PTkNWWm5qkdiM/YGU1A5a0YkGt9dKvuVklTZVYxOGEcwtboi2pWDKB9wvwmuBvDDIPgZFBuiWQajYu7qpxq2PQvvXznuSozsfs2oI7Zt410X7Kewi3pGUGhFFNSY8XBA6sFmjSSIsfoKsH+Y3vprFsoAwaGC2yM7BaOTZsySIXaesV9PKNErimYbLJOXXo9Ne/+lragcgiIXtt67uK2xg49T6IvcRDNNDqLt5pcoDOize+/g7FffXTlAA097O/dySrDwl2Ophv40VHszcp5snA23Dm/DQvFziX9gctHunEeWhySBtMzgz0s4QIYgqBeH9GcPhWISVqZYcOK1fqyAz5iIA+CDYFx844KEyMAXmDSsA3bUbZ1Ag8YqFxugha0VsUMhijGJp/IEkkB/mtghCIj+PeDGixVLkE9XUABU5Nm08jEgWKQEsRmsOyq6GLaJ6nvYm5xT6SVoX/6UdBP81ofUm9Keis0aLc1VJdx1H6p8/cL8LOjrvUXJeWwXplcOHWsdAA4ub/G9qmA9B1eTkFd91ZaMNM7YXoDapb7ERFDwbqobXqM0/LIt8tYvdzCNLMwc/FcHZHsGI0LKYn69cVSamg7fMmdsbIJ0BZwRkrSEfMFyXTnxZpckq0ZJNt6MCKUARGLbRqHbfmBFIv2JpxHBVM1ASGXow==", - "commentsBlob": "tMoQaTrBBkPZ7ntK4+r2Y2h1bYfivgS2EcuozrOFji+59M5HILRL7c46U4xfCAVKNBs/W5JeAmKN1Pf/M5SRhC/MOYKKFlLh0k4+/6IYotwFq1pkuXs3ZNPJgo+P7tdU7OPgDPNlaNWYhocmhlNU+BUXuAbF4wpjF9piQU8Q8F1Tg+4wUTUqdiVOey3M0faz6+rTJZZ/UfXYwv6t6wRYPJf5QQA1rrNFf8QRlFTcpWxRruollNlAs62fnw+lkv3WrKluhuIwciIXFuT24OE7ltS59g7JDTzQml6lQ1rTzgzCqv8fab/Qpl6Qq1DEeQjIV/RkTpvG+klQv8PmHdajbIWrecOKG9cnu8wcams15uEujzY4fU9MbbZxS+4RAUkDGWirQoqgeeN2prbi5vyEkU1ifzmd+6Yhcso6otT7SLfVGsAsS2sHCvCmDMqi/8Gb2nWIqPk4e2DThKPPQ7k=", + "employeeSealedKey": "VG5UEI97bM6uJnYm6rAoSXsatOm4A08mQnDd0MUTalpMLzlq7/jgLZP4uj6y9AKCQ7N0p9sqqdOPWKDi3qORuzMXAzsrRcHTpgVw7buBgMQ=", + "managerSealedKey": "e2BP1enlyjA5N+wDY+drzDdG4f9sAlnaVIu1aWRYoSTTgFhQGMc/oRJL2J6nTQcz9fgU+hysopYbJcgY+Ew+rFzfYvGk690gDuoXGUsxcYo=", + "employeeBlob": "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", + "managerBlob": "yi2lcUD2XdyCFqUTAbf7lPMZOfS5kBiRbrvu1ADRp/49mi016Spiecva+x5VWupDnwJb/4C37RysCQU7Zts+uar7HTNRJ2daTb1SV7YC/ztaDkN7t/DMzGVR4F5dxL7jTNFdhKwzarcwsetolNO8/MPSkc9o1P19otl7g+WS6vbiBRehTGNJyTbDUqjhVuamb3V3Ub9RYHswiZwUpksVabA3wq2qDjXWTFhPoE8XfG2tj5DulDt30HhyFfxvhr0Iq1ijOcv9aiGujNR9R00hmOrxVrNkJiMNO8B9I9m/1YZMKbcQh09s7eZJwRvbYCTldiM7M5iw/dChaSHpC0vqlQKQ7mXm4H5q4fLFkV9wah3/RrcCzTTq+MzGXSCwQ1DBsNEnguD5R78nzuPtD3T+Spkh4MTaxOlf8RVjAzplNvl9uH0BvCvfP2mr7VUePezVjYW5BXtO8Tto91VUCVMnq8vSxkfntdm0QCPtcmXRrKSkqrs0DdfEwKjd8BEYvqhxZt+dQLauf5EfRicw46LXbWZciKUXA+1R1wIDOnsH9cuL6+hKCubqQ3uGZRebrbUSZ0BzQr1XRlGtL71QTJKa0difJcJgXF3eeatn0eoBV3xEegbuUrWmxFRthI+KINKQn0Ey+IecNHIg/Fjgj41rwKWMF2tULjX32NDvLZ1Wtg1wsVjF0eDT/TEDukXjH8lbjmKex4zImkdQEmqbtEkSnf72K/uOnB6hhM1gzzXrOWPhlQXhng/RAaXfW/IHhtGp/WNip49VG+VivzR+jR3+1KvPsq8MuyO2w3LjGud2c7xDsbZvCYC/eHmWj9e20weBPAaSGx7EEkhinLckFHg/99Jkcob+kr4qG7X/eZxu2UYVZW0lfHYKWX9tBNUjCgnl0EWmewGcndtQArT4yCk47UgLVccn0L53H7Tnl7Ugj6acA1TdxvpncBianZqDHQJqbMz2ejSDvvMsgUGBv2kHiVRo7JvH41kJfuk2+5EQPXNtb9M3NdlgOO0oc8YEnUzjjaEowJGJlmPWux2MSgbYfczV0cXJ6LTLfShtdzmiDiss31APDdm3g0yUXIe7ZC/s/mAfFcj3Vizwj5vp93MGjqNpcvpNcdLv6/hiZtkanRHH8reS/JPJqonqz9zbZ1C5Pwvorand5O2aqXly6BnwU3n89NMlZ9yY+2jBSg7pEJkCHB+yY03pwoYOe/JrFfFzlNisDTWR+C3MSxGvcC5k2WxwIsaJL5gJhkA1ghau6B6358OaLasvgzPUfo+YekK/7bGMnVU1ak8TkzpyXItM6WvIFg==", + "commentsBlob": "1rceaqT7gVKorBd4A54B6rMnggqyJ28KuWiHv5jsSsO+XEXf+RGymJhHpTEigf8jBnOdGx/uBW7Adbslo8vitRL8LskSSusGn8vYqkLj4LSVjg0N+jYrvlI4KqliJz5rUJOwK882euUOV9215AMkmXhR75UO1c0CPKf26rOcEb4kM3y9BWx18qc56wQvwwzLLGuUDPL0aL1WCpR8aWj3dJf3N+tGowLgxTQcTGdAtJYRYt/A/a6Ml6Jic/h2/DNCgKwIcmf67gDTR6NTodL88jbcWR893edqCrKqyDpUAwqA3TMwuyI7k08pcSdNzwVtWEON7u3MvmBJoQ9+9IY5zTYmrqj7O8fwBDoZzPZv4kcLJFdsoPhpDqkki+6Q/WBVP1OVoM8MaT78XkRmBoaPB6XWcwYJMaKO4GhVmETP92wT9eagpOnOfip7ASrVmw6C3qrv+iL2R/9S8E3AN54=", "commentsVersion": 1, - "outcomesBlob": "oUAPoE4U0Pmok7Xd90DJ8QbNG81hwD9NTHEPrm0ZsKLVSfG45SQ53Z4WLdNgVnKLPTVNOxlDdh/P/9YXCYStYWCdj4L99VEjDYBxux26EItAcyn/FIk/XsSc05rhtC4CcRekqqubQpbOhAU4NxqSbslEb6DZRPVzhdmJbM9w1UXhpoQTRt5XXT7kmK9AWDyJfOwrKJr2ZjdxfQ0lhkgDiIjRn/iE3i/B2TrB8jZAXLjZjin/DXs4dGicAykjChaWwi6szLJOrO6pv3q59JKUaGaMOCGDmaLBawZDI8qYIYFxsrLyo+D3gTmkKMnguGwox0yl8YEeXs5HpxjBJmZtjtvjKLFDOZXm7xnkdURIFWBuSiNb4EK4FGSIC8yxfyWgcuM/lhvm/hXbz5Bs4G88vIiULS4q6DbJOGyA2qdPIbrrRjaB1QEEl33rrjKE6Mfsk6j7aNMX0YAhQSQ=", + "outcomesBlob": "D6eg3VXy2PgIVvd9vwPkk7wHL1d1GG4dOMUk94gxPNkWAwm1evRqxxcKNqJ9YJOrF1rIN1WBJnMducNXDs3sC25XYUfUP4TLESLvCd/hqJm+XnNo69GgnjZUcGyXDhwdwm+Ve95NefL1lWLbyfZMV0ckWuO/Ldg1ziggS1C3eUVXiCOnniTASL2k6Ox832YxZ6zbgYvKC/BWYQfPJ1LNQJDBV5DJSDdnbNlKQeiXpPe9/6shb54Z51OpUcnw6R1ApHiQywE17Z9ycsAPHtFw7EoKifqAuYTZnxyOiLiTfIoDJ2YhscAfG4QA0ErngWb8tokYxXN7tNTj8TiHJSkR8zSMT5LoMAkphHSC/N7qxkTvPed3q+yAyIr7KEaMO7m6SXE0nrfyfXFq0tLSqycXQzeWeXu/YukjI8kbJynzltpv7rWQYppMigq2LT/nS5jK/P+plOnuuAW6SL0=", "outcomesVersion": 1, - "goalCheckpointsBlob": "snlYJB0y/y0SRuXZuARPdmhgHR/saATrBK9Doc6XZIdegTZMNrwcVunlfI1xBLLl71LO0aDSqaGianQxm+nsY1UnNOedm4ShafWV9mX4qXbDAu+rKiNg01BTVf9FNw1RcXSp8iveSssPxZ/25nqRSSeVvl3hi3bNS1sKLoeExg0fV98njXokVONFXTJJBlWiGmA7KZTpFIsy+ntrubDBvEaUWDCJ3BPVk1Vp8rMHFU/iMy4ZMP5Bv+ixAmvZZuWWIfZ9/gTDzOCHP5ygjFj+3qoWc6MAEZGBTay4BiJCQMU4ZEGxxkWegIBH5gbt4JVY5L1Ey5NgGDNt+uoD17tHkAaOEDy1OcMRrHyKTB/YxFerPxnNzddTLRsZunxybCSrwhhhBkWRpGJRzaLajA4b0cqLs5BODazK77IphIbUcR6xDqJCj5uDvkLzaxx4kv20Nmav6h0z2FejJjbOitbWAzX/U/cGCbwHeYFWlKRUHzFpD/s64SVFDsRFMawteL+YDWOxvm1GweKfcznQ", - "goalCheckpointsVersion": 1 + "goalCheckpointsBlob": "jFT40gfgSSxCE3D8FVcklqMlPNy3ZwR1+ZJuYTQShquIVeqT7obf1dlnpE3u6QSG/V2wQBDpwqIIdLP/ST8QiqI1YE/Y9SwAJAsLtPeQWNPHEyFmg0Vs2CxMDlnVaWraz75FMLQdsQCOEqPJ2AAG8o0px+XO1O7LpH2iXrAzaWirXEV4n70VnXQva2lh4x+Cb5NVQN3NOr/TdhBe5OgFAPjCZpzLaiYhcIC8HQ6WueBYLLd2fKq5FJunab0qydjQeysda+StJcujRMl90frlbp/VcNpX0OmKzW0j6vwDqX/O1d24IA+WsfDIZHd/Z2QbGv5LNGFZOt5OYXfKtkG+RI15boAryERtZK7xLSsMXI+zE4apfyLudFBUYgAT5/VmhveSpcfbCbZMTWRsbk4geJ9fZxF6tU/1c2MFgxNBaF3AmcDZT+MXCZi0j6xT3IolSt7wkHeV9j1GHM07bjLdAN7pgO3tMsFjOslnYy4Xs5Niycn6mR9QeGmhGVudSyi3eIEpF29BalLhqRoe", + "goalCheckpointsVersion": 1, + "topicsBlob": "hBOCn8P47Vecua3nm+3CPjDYgjlQNhJjesa6tQLcI82Oo5FC8cw0SZMeHDZ5HezwtGH92MB+ENIQjayGSfShp2qxkGVHPf/XOeljfhNmoycUMY9T+DX8DOp5Ys4l2cTnAG2TuByV0WjjAO4MnoVynMsBVwrKlzWdjCGcXmemDGKiS7Xlz58QybNfjzUVKoB+TRd8ISUd2dwoTeg4Q+K+uQ3SZ80fDvXUG/3qAKgQkmYcJRVYkqkPoorJOK2d7WIkW5N8BNJwkaJcAfGHhqJHccJGGt68an61OesNc1Tazty4dGGT5DS7b50jNwqMnZbpxS1XxXobhXX5WlQUt/nbQ///3ohQreyn7kl6sNIDz7UInRlasa2wEQYL8CE082AJY8rP0r/0J/PU+87cw9bG8MSFmbI10HuxBeu7Io+siUV5yhJ0i7ArOOWl1jVaJ+kRkY71UVKKQJI3+6iGbJzE/g4DHx5v6Fh3YMprQl2xsvPeCokacCi+SUHFoHGv99Uj/AsO8aFCK6tmMLDDQzX/mlXzCiXxBOt6v4w5qfml1g23WLKBRAhiDUWT5QPaZOb8pDEcT23geH7Iw80OyyoPN0rMnDhb+N4iIXH6ArwQHLEH9H50OZBl+Qm6DNQDKsqa1fm/efcEIG8X7ITBLwkObXDTSlDoNOu4Txu9RXNh8Gn88DPTKxuUexS1IPuXEv8y4Q/t0BoQRaDR2yYdpGI0ojiHKKhTZqf7bYIFP8vkpvac5DHUvcWRIdz8bzxEc4aftTxeizctmn/RMtH4qIEVLeAhZI3mp6orIdEwUGEvv45Pf5JNqzDO9CmRi/cczKiWeZ3En39k5ifYn/0rXJgUHJG+ZTwuebCIrQ==", + "topicsVersion": 4 }, { "archived": true, "missed": false, - "employeeSealedKey": "X/jTaU0SmvBwwY0WvrLEXEHvCEbGasz4Lw1nCam+4hHxoCLJ94XBdlumCvq9TltWADHAaUmha+8IvumeFd+mhIcqpuBi8dH1CTQEE4JqKU4=", - "managerSealedKey": "EUOt6a24TB2lj7Q/FP3R1AeC7A5MgDIf2cp6wJOhyTKIXaqGZ+MZNVlhS7eZb09lwOP4DhhGex4L58Hn3TQH4IzQIZ7Xy8r4CFwUSYYhopw=", - "employeeBlob": "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", - "managerBlob": "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", - "commentsBlob": "H7fT4q05KbTy2CLEiGlKEU470VmoREDK/50kO/1ZFWOdVaJK7Cx48I4FfvIjapamNcDj8EGNiKdXW082MMrFx8r9EtWMvFzMZfO5pl4PTtI0GJRwPv2fSAZ4mZJYEAcqj7qSdQt7BDEYVJsZQnH2NTw6CHkpOzKh1aPuKAnAAbC9OySVhcyJpG0keOT8jYGbIcxaBAQ9mgVM4vBQYDKW9EuqMi2DqoC0YyvwfJSmCQwT0BwpT1icM01cskigKr7kjWVMAtCCO7pS6hN5Aeb0FJYvrUUp0gFDmOVDB4WSCP9O7IsoLsTgtZEVGoLrbNyWv6ln52Xdzf6nCCC1vJnfdV234FN4/mXyJ5zdoyuZond4RFpZETLaHA==", + "employeeSealedKey": "eA1tDlbUl/N1E7m9Jb/gmhqLipPgnYn1fu498j8/UVyqr+4zaSCaKa1J7ByjRRg45xb9DArUi0GBfoGlqhzwf7ZooumDEIW6GMScG5bbsEI=", + "managerSealedKey": "r7HyTfwUFFNvRmy1/cN0DGv9nrauoh0c4e6lewzCmHyn9p8Xz/osXqhqmacVFLAR2qz+jRnEHcqi06ganA4b26gtI/1SnXVWPedf76py2gs=", + "employeeBlob": "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", + "managerBlob": "SWgV0ZEynzRTLltBfX5hO5EsLsWVynZOSGQeUfnIk3Qh2trX13hKqUr8wxwtDSs2hKJeC1YTQ10jWcGrp1poxelJrbJBhyJcqHmkl4ygQ5rhPUKPtBJhGBpZWQWy9stQhMlS/VPNUtpA0l+mc/IIUDYgujYQFvpsmGbbMLC7FMiIAg94Jtki96qubJZt8/lwhma/pETUivj3wvW/8CqUjsqv+jtVzbNl9JXFZe3gcr6YNuXzxJZB8CO9nGIux5vQVbqr2uSqJW30hztA+sTqILhlVwsnUP5UTHHDM7nBOhJBue3bAS0AzjXN+wwHlbne6au/mhSoe3rRkaDkDThIOHVUVDPuqKsL7kVM2+MUgyZpKlKW1oHcZ3LdcPsuBhX8qfEotw8ineRnLOlBk14GD+BqDoNf1vc4kdIamakxgySPmUSK2NQ3iXkNmNcykNS9DEEuDm5qmSDX+wheafR0KzTa7Xr/bsPTYUDl5MCFpeLbO7Ji4eimpfPdeJfNwPqgt2qcPmuLkflGsnwq/xbA51nKmaouVWLdIEWjXtK2gjhqcT/g0xxTQ/qUWYFgNvKKZoJm1ohIUKZOHcx6qowvd/zNYgV+mWijIfAYELpU3KuJe/QQzmI3Z3LwBDLzPS+CdBxs95SXOWVR+7AuAiKxWPZJ/fD7KyDYcRg/i/XtuOhRU2HeO3sMW4cAlgvWBgPfw8ie9Ns7bu0704p3u/mANVeuAtesav3CxyAMrquet6dOgzS5p2DwmdjOzCJX0VFFBz389vgVusETjoRktzl21w8yGMuMtB96SKekJtGLGSN9ZJcKrVxHVXQRYan9C5Q0CC6fF7ToXaQ+BD974yFx7lSVwIQQffgy6oPtmg/8nfDPmwAF51JJ66Rcvg1mSiMWuWmgj+/KfijBb9JA6vNA/NbKiV2EFqO6ss9wpCrwZcFzo/OEHHVOSSGp3naKMAKaTGqiHsQjGjEIlV6DsHNHZ7HrcdLpPhhE1UHNYPOuxLawPaVq65+v8x0kfAiY8rRKokrF88M4zPyDNkoPokc7bLw9WzSYvYMCMkuYDL6NRl1nhUP3FDRJcSPlpzlgCjpIbx6MzZAVw1MCcjlVXc45DKtV3yL8aScm4hu1nnO61zj+rX/Hl+Ld4LPN3PSqEoXkc/XmTK7/uQfGAhH48cIcpTwKujoNEzClkRIV237TZiuixLOD0PjCNH2Sk0MnNmUcl09fOHFfMkd2MFoANEo9ymJvDCZzdZfXYRhtYa7abzMLH3rWXNfcbOjsxpLzQlPhjhziFsoslM+wJ4/d+6RhC8fCyzR4un+kh19bLuM2mZ4Ue6dNxMTRyLNhgYggNBqiU2uKHMhkhy522B6qcGbTMtzHR4jAmawUlo+pOOkM2wc9qy8sWh4mg0WinyxIN1B8u++iqzOiLGfV4meLoK42ghwhyrhT4bB/0pEtEb8bzfI5wp0QlEHzzRmDFJKiw8m+y7SNLogTWylJyWMq0Z/0KHTotgS8zdSsH9F+JG0k1UYgxwhgrHAazxvxqFY0a8NhZoRgvvHrkrkZIcHDoUfz3ElTNh8OqkmgXDgUJw3kgQSxwvt8Iv0Re7QDPIDpMPs+jiSX7aAuvZqKYOIgAejP9LaRw+xdqmoRcCn88LPU1p9y+ORs8O7F5hICbbyFsFi5H0DnS1By32e9ZknuY2Ni8/a/rKZriWlgbMP70wbxgsVJBqrR+QBT0iUl12Nz1VFYVy7rhIXlgA5OxPD3PM6NtMmkAdY/A1Q4prtKOD1qTpq/i/T7a2fC/p+uq/IrwzCpH7AbjnNvAuexdf4b", + "commentsBlob": "OuUzcL/Wu4fPaLYKnA+03eDXaO1G2DvS/V7a7ky8u2nE29ytVP74Km8RfNML+JVYLG0vL1fih5dAei1MeH2XLawgu2kaA5tcPb2L9EkESEygbRE3PYcuGQMczKdgJPOWYXyKphe2I0LzgkjzslK5gMv0mNTMOBgGbVTHey66hJrhWOtxlg8AnwtagrjIOCscuPMSCLqwKQup8Js04silyHjw8nQjjb8XYl/n184Ep0eEIBrPrVO5q6LBoR0C1XQqrY9UoEfjN4jTuyiACIFDWidMp/rzY0e61v7BBPkcxSwEe8yezyw8FhaEgUIKfuDf6BmZW6ImjQWY/TPPVb73ZIa5HFjz96rm9uqLsu2Hh5wyTw9PkXvDeA==", "commentsVersion": 1, - "outcomesBlob": "4O0jP8xy6fG/S15hKC/sYYys1beZ2SSFfdtZWb4EG3spVrx2osvTDXMGtTIP6nBLxYI4mDBz7A508TScqmeUtaSJ+KzYMbiEELFZ5Ppmr7+Ex5iHATAnpecoWfXl7g/lpaN0iQfZ0Ia8OIzqsJBjfA4oyDHHNO/t6PeY5RqVnJ667hvWGuGB4UraxbgOtLQ4Nop4AeFLc2daH+5O6Un+w4P+vmmCHawtHi0nCBIobUFpv7YTRmRqcPnDv1gW6bCiDd5VMIGKg/x/AtVhv/6QOOb3zbjJ5BDDwvcCVkTfZIsFsFhOZBuI2zgdKHO4MJ5wHgoxPxOPUBFN/YUH3U06KGwhfCf9aMzeNSis5K30/AtD60seMiqadQoxwG9SXzkkzySGCTBkwJ2ItMijgzyUqIPnyiyXqgQQrGddxpo1vLzjPH98Bq0Nf+07RkclM9jz8YNBheFNjWUQv6gHjnLpxVYnFVxnrTjb9e2PUbJsIilE+P6PlqHbgZQP8ZK0TN/rnJqmQH6T8gmfOuaRq6IS92n+HIktSSfHOFJ8YwMUyUp17tBLfOp3QXs4KFmTthUcjpP6Uuu/J/NG9Z0w8BgRzhrJUo713GcvgmNTswkvOyMYLyxNl7sAjTE/RlvebwwhG775zO04PradlIwoPCcqfekggc2h/dYEYlwXeOWYhwRDRpeGIm/pdlV1uSKiOaaNVfHjQ+nKC3/Xx9K8EPhR1mH4GMCH8MWXKLNFKZbVW675/8fRqpUg0K/D+eG6CFPgS8Crxp21UsbZ5r0/DGN8q2/yHwYqLlXS+haaFlWfkwcsXcQuvsN5+ogzFdf2NBd+evkm2CGp1OgJXAkeksy9", + "outcomesBlob": "tzb8T1m+kqWVenUARrXQ03WhGlqHjBMpubCUx/M2YF8Ht3HWc88JI8i2P4LprmRhp5Vj5glR8DWLqbgzwuy0WvyVbRgxsjQAze7t44oGhkMP+SEGp+Dv6VjNM8WBz17hckKrE7oNHign6DDR3xRWCE8XMaYAEZWIz3Ej0DNp8t7lyvR7bAE2bUjlZJ7TV6doMWnNLhIP36Krvb4c2vzebOWMVsWnTYtVRxYqbG78XIZoQu+D2+QCaWvJTUZqcMCtnTvGZr6+LW8N5sZ0gynNLdW5uRtnR40qa03R7h4H2DzlwLkzdD7V8S7gYT9ow0KNdprUUvl7T1Rs2BkT0Hb2sl/HX3uTJt3xrWUk8Fbh9QFQLG0se0LashpKrbaZVhbznLeoWngwk5JtChAw4MK0Sf1nctQaTXj+hjdik58I20mQ2HF4GmvWl2NJWz5udMRR8PMbQIXXgDzZ2rWHAbdpRb+1dyWEnZiKyJcO7RSFLYTZMGKlva3SnsPiJLp27TjhWU2lP+SlsLHTlsuPQhCBLoDdLDBHyDRxPiuNvcVSS8tKLmJYhF8oOR5Oez/Cw+s0zWVU5/GyCwq/W97PW31fexLOoKFmgga3RKCTmADT3cEn5pODeTZmYfSUxLqrEIO0ILTXoHgTDRoIp1fBeTAAEsHJ8oSWie5+QEVA2O1IkKVitmGZpLPnN/bu45mEKv5dsx8L7wonItS2JzAC3w7Ti3Anei706GGM2fRTK4ECVLjeD1ielMeZtGye2JZHcQpVm3+R53gYyDF7cLt5B7qgmDeRLD3vsdFJcIYLw7xDjcshmQXfy74tXbQ2IhCruTO736gBSBEmlFttkHo53aRq", "outcomesVersion": 2, - "goalCheckpointsBlob": "ImwvWHFcuSO6ZKiVOMEFrc8pO3bGODw4ZVtZjJf8g61NZIOQhxD5/V3nadYDVoE6uB9IxT11m23b9nlC6tVmwG/T/tRRHt+pylFBoX2Y2xOjQiKu3/oSSNI9GgQOrvCuemaIebVbmFVFYCJ+wHzwm4Y/eUarBdfwh6pE4RyNf6605RqTh8S9Uhe7WpcfnFN7cuCzHzXziVes/5lZwGHofzWlMtT33Bc1x9GdVveQI7Li3iFroTXgvPeqgoaOVGiPk7NhuaLWRnEuOToNzQXjSAoXmPb47W6XYwD/XY7IkC4p9momhyHal/K2OClev7IOmxUDx36KmdQYBjwabWyShe++noH7OLPS8R8vrQRWm0qP7ujPPMQkQfdl1uGrQNSUmRQOrDCyKSMKRC9T5rMvybKtypXbSL0TT6Hzavppzzb3txmd2x2fT+0GhU0FJe+pr1tFqR+8", - "goalCheckpointsVersion": 1 + "goalCheckpointsBlob": "wvY3evshj2dOySKkBbtNE0/bkV6rRqiNqRggdV8Ax33v7Et3/BPHI5t4eI45TyGOiv5tJLWpoLl4luIGAih7gQr0nZ9RzMyVRhy/KxSvQA4C7O5c1bUG6ShcBU0LNgTP60+GbABzT1dxlso6oJLXXae4BpQFFhUCOlXm8nlAUX8RmY6kKRaV0jKyUuF0rRkXXkjkhzZOViAPfUGBcPBjS+PpHADMCV6jXg5DZc0sLExAp+vvnBKZDyeD1tiuXerTFroNQjA4Ote9Pulv85C0SFZOBskRlvJKgTW0GMx61AXfEDnjT3qDpHWftJ/rBNIxu3Bhv11YwsCBb3Ylfq2MONdsJKvh113j/EWa0i6sKzDQwddxOLI6WQPrGdeHoarfr5GBQXOEM+LLeZEVU7JrL3wiPEJciuJRlEviRyv1WFtoQmGS6Hva+faeGuF3OrpCX6KXw97R", + "goalCheckpointsVersion": 1, + "topicsBlob": "o2nwfFvgDYVNbBG6NedYquDQTBEPFLQZGkDm5uJQbBjJ0I84EeUJY5iTt0LUC3V6nuwUs5xROdzoRdRGjQx1I0OMyQJsp95E80hJJCZfAmyhGDZEX7MfOevSArF1OsGhtgutL7WGY0qBmnOmb55/EfjEhuYCyhnGKY5upxvbRHrFEGxmycJUfkeLCDKxUntLZinxJatLFXvEcyFEqtqvATrJzH4X/jjdbNiSEApRnYTKH+2yvuSRJXU6CQWSU7o4c5ln3EDvl9/b1xo8odiYIpSAL9tjGQ6g4Hj5X98c0oubtsxmVsVqUIfQVnu/LJ1RGpzfZWy7m1asVO/Q6oh7upl8r2v4W7qazyCzXssuoLarsbgNLC934Dn0v6aiI0AZbPzERuI5AA7EAEggqOgxWIuCwNz8gNFI1cKHYZeEjTxiGAUBPoFeC4TzdbGp2N9WiCzUCjdf19zhRpyGySBjCn34R964TSMO0F3A0m+AK1iaQUgMaxozDuu+N1j2WessWN2gfEB7jc0CrMxDvkIRhCIetIPNuUqyiJS7CHLnp7suMFsX4qVBc5Tx7MoX1M3p7wV/l7ejmQGL6ue9lJDdW2PnuCZ2SKab39b4rI2jrWDIidiYtSFP3C0XoPoi4o/eUAn76AxkdFRf3vPSCrYuIhJUEkCTpWACU1WkHRbhMbTMP8QaMGjQS+lbkS/RgOoSp7HyMwDLgqKB1njjCkWF/SUtgYPNPWdtmFjXVuwHqpSOdjYQo/zi7TUoGf6JSrOtqBGdcgB/l30TBX+oKATbBtZpi7vH2qAOTSdr2QXafuhTUb6UzuglzZThbP+bD9bGogc7p5FALOcZ0ec7rJuifsXSu5RW7rq2SPUHk+ZyrHjlaKso", + "topicsVersion": 3 }, { "archived": false, "missed": false, - "employeeSealedKey": "TrCUiwkp1ZgYvIQuS8/fAhwXiv3SyIa8I4FmJueDGzr16yBATsMh7ZlaB3baKVqmOTfP7IAHzhXqLfMQwDjTGFlJz9T6sZCWAi+Y6XQEqm8=", - "managerSealedKey": "Y+xacp3GHlcQJ2bYJ5fCrsqm1QPh5pb7tw+Ze0vXIGJUDblrH7KRkxmf97344aOua29QVtxHAgPbf6RU+Le2r2glq3QMkxeQUsejKz/AOR4=", + "employeeSealedKey": "2IrramYS0PfdqsovKakG9p9PX9g8JFQELh3UpPkiSk96UT2JxTKTIdBWnAuUNqz4ew8uOs/O9j4kwrhV6yKqV/IGOiRj+cfj8v7iJ99c03E=", + "managerSealedKey": "XrmQhs/8PD+d7Uv3paAnUB+zj8+khtafHpkWUkT46SpuWPtCZ7jHMhnNa+Q1BJYsQAQD6ySKT2+V0IZok4ktwM6FiA0iFeHZqY072cvj/Ls=", "employeeBlob": null, "managerBlob": null, "commentsBlob": null, "commentsVersion": 0, - "outcomesBlob": "JNWHTHYnFBA1AXyoIoE6wD0KdR0gL9hFPyJLO6DoYPXw1lHZRw0RwzEZPfXw4LU74TPQq7RwLqLRbbe9N37Ph1UCF9ONA2hTmvtP14ZlYzUsA/9igLC1hLakXWkqxVyZSY1pRPzqgym0U1zNk4wPlNPfnBQgFBo0/F6uKKSueXWG6L/YlJXqTPJxtG6qPS1Y5salrANCTsWyD/ClJIgUmY1DRK1+An6z3WzxV3KfuE16A259HX6ycHgjug+4svB0+pwI+XDuGo6IEIMlDVT8Ucx/t9I1TsOiXffSFQSibCx4BAZNgZbMef35wyuFnIAUWRrpwaqVrUQgNZn8ePwwSFZUQGzZkksSggjDjwkYA7KfN2W2P0VaZ3SX/ep6v0yhXmrKgRg0K/ImJqqk69EjfC4cEaT4htYxJADhTlSF2jb16AuEgO0bZJMBwILTom7k0QAuSwUTLxA/gnrP6heizCgp4jr+1TGX2w==", + "outcomesBlob": "nfJNjZtiXsEPMZXm9oqsqUHnyc5AS+w026sggjgYqu2cTtctUFNvpn0OnZgvI3Hq9NCkyf9b/nRmWkLF3OJURiHwamWjnLP+MZ2deV+LlxlE8rFXAC+xHq5FVdVoJ1Xf0BXerdX3SnpcDZmA5qoGsua2+GIOr3zBuwpde173iLlDCVX66txHRBFpBXwSzSALhWaaXIaIkrxmvjBnoD5fhEDXaxOHzIkcsjvOwjvkhDgy7prHQbFUCYNn6WhhiLWiS2He+cvdRKDbR1ErKKtQQdTU1wRx0ZcGooNVXL0c926/rzOV8yvG9BInervtMFzF0BW33lYC8a84869/UHs+QcLAFZ9LaRBHPw5VubzYACEP0ckN0etGFln5KxjzgLiwxdA5GuJSbcvwg0D0M+0wvWexDGWtk9BvMPbhb7eH3e6fxI+BpS8nyNND+sB8/rSpwcogB6TaQZxeYj+41IGrSXsTegX3eZFIrA==", "outcomesVersion": 0, "goalCheckpointsBlob": null, - "goalCheckpointsVersion": 0 + "goalCheckpointsVersion": 0, + "topicsBlob": "JzY03X13zI3WU6csWapWOsUd54iwmqd5QpOfSZ5lzIXl+CAnQ/9rk9JgXVN54b2Tqhy9kemhi5Qc7gZY7/qrYBdHm+myvkQia804O7xE6Ivvfsq30l+fV+MmtZv1wanB6GpL4OAEwDEFwIh0yF7BRgYgaHgx7aKHzxPP/EwW8/JlRvNqilPaiHjwz02qPN2qcN0Hnr38zTUSdFEDxG0dHDM4PR71HO2AAtgv8goPC4Hu1OtA3WuWz2ZOGvZSfdv3Y1vRSLaiD9CmTIMlWH8DFoovXu9g1ymbdhFs3hOzLG3ycVsW71gayGvn+sxQax32vKO80cw10mdJBn20xnwYh2xALsuT14IBxdx09Blakq5BmENG84kWRzNHn6FP7HVtcy3YaXjpeO8VabO7TbAuU0A7mPRE3tomXdfcQXMLb1rxPhxkFpM43Tvu6fs3i/5+qFKkPH7JzNS2keUQbGwv3X1bovvDct2A", + "topicsVersion": 0 } ] } diff --git a/backend/migrations-mysql/Version20261004104608.php b/backend/migrations-mysql/Version20261004104608.php new file mode 100644 index 00000000..d57cb369 --- /dev/null +++ b/backend/migrations-mysql/Version20261004104608.php @@ -0,0 +1,30 @@ +addSql('ALTER TABLE anketas ADD topicsBlob LONGTEXT DEFAULT NULL, ADD topicsVersion INT NOT NULL DEFAULT 0'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE anketas DROP topicsBlob, DROP topicsVersion'); + } +} diff --git a/backend/migrations/Version20261004104608.php b/backend/migrations/Version20261004104608.php new file mode 100644 index 00000000..8c93a85c --- /dev/null +++ b/backend/migrations/Version20261004104608.php @@ -0,0 +1,41 @@ +addSql('ALTER TABLE anketas ADD COLUMN topicsBlob CLOB DEFAULT NULL'); + $this->addSql('ALTER TABLE anketas ADD COLUMN topicsVersion INTEGER DEFAULT 0 NOT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('CREATE TEMPORARY TABLE __temp__anketas AS SELECT id, meetingDate, employeeSealedKey, managerSealedKey, employeeSealedKeyUpdatedAt, managerSealedKeyUpdatedAt, employeeBlob, employeePublishedAt, employeeBlobVersion, managerBlob, managerPublishedAt, managerBlobVersion, archivedAt, reminderSentAt, reminderMeetingDay, followUpMeetingDay, missed, periodicityDays, commentsBlob, commentsVersion, outcomesBlob, outcomesVersion, goalCheckpointsBlob, goalCheckpointsVersion, discussedBlob, discussedVersion, createdAt, formVersion, templateKey, oneOff, employee_id, manager_id, company_id, customTemplateVersion_id FROM anketas'); + $this->addSql('DROP TABLE anketas'); + $this->addSql('CREATE TABLE anketas (id VARCHAR(36) NOT NULL, meetingDate DATETIME NOT NULL, employeeSealedKey CLOB NOT NULL, managerSealedKey CLOB NOT NULL, employeeSealedKeyUpdatedAt DATETIME NOT NULL, managerSealedKeyUpdatedAt DATETIME NOT NULL, employeeBlob CLOB DEFAULT NULL, employeePublishedAt DATETIME DEFAULT NULL, employeeBlobVersion INTEGER NOT NULL, managerBlob CLOB DEFAULT NULL, managerPublishedAt DATETIME DEFAULT NULL, managerBlobVersion INTEGER NOT NULL, archivedAt DATETIME DEFAULT NULL, reminderSentAt DATETIME DEFAULT NULL, reminderMeetingDay DATE DEFAULT NULL, followUpMeetingDay DATE DEFAULT NULL, missed BOOLEAN NOT NULL, periodicityDays INTEGER DEFAULT NULL, commentsBlob CLOB DEFAULT NULL, commentsVersion INTEGER NOT NULL, outcomesBlob CLOB DEFAULT NULL, outcomesVersion INTEGER NOT NULL, goalCheckpointsBlob CLOB DEFAULT NULL, goalCheckpointsVersion INTEGER NOT NULL, discussedBlob CLOB DEFAULT NULL, discussedVersion INTEGER DEFAULT 0 NOT NULL, createdAt DATETIME NOT NULL, formVersion INTEGER NOT NULL, templateKey VARCHAR(40) DEFAULT \'regular\' NOT NULL, oneOff BOOLEAN DEFAULT 0 NOT NULL, employee_id VARCHAR(36) NOT NULL, manager_id VARCHAR(36) NOT NULL, company_id VARCHAR(36) NOT NULL, customTemplateVersion_id VARCHAR(36) DEFAULT NULL, PRIMARY KEY (id), CONSTRAINT FK_865B0D848C03F15C FOREIGN KEY (employee_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_865B0D84783E3463 FOREIGN KEY (manager_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_865B0D84979B1AD6 FOREIGN KEY (company_id) REFERENCES companies (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_865B0D84E3829923 FOREIGN KEY (customTemplateVersion_id) REFERENCES custom_template_versions (id) NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO anketas (id, meetingDate, employeeSealedKey, managerSealedKey, employeeSealedKeyUpdatedAt, managerSealedKeyUpdatedAt, employeeBlob, employeePublishedAt, employeeBlobVersion, managerBlob, managerPublishedAt, managerBlobVersion, archivedAt, reminderSentAt, reminderMeetingDay, followUpMeetingDay, missed, periodicityDays, commentsBlob, commentsVersion, outcomesBlob, outcomesVersion, goalCheckpointsBlob, goalCheckpointsVersion, discussedBlob, discussedVersion, createdAt, formVersion, templateKey, oneOff, employee_id, manager_id, company_id, customTemplateVersion_id) SELECT id, meetingDate, employeeSealedKey, managerSealedKey, employeeSealedKeyUpdatedAt, managerSealedKeyUpdatedAt, employeeBlob, employeePublishedAt, employeeBlobVersion, managerBlob, managerPublishedAt, managerBlobVersion, archivedAt, reminderSentAt, reminderMeetingDay, followUpMeetingDay, missed, periodicityDays, commentsBlob, commentsVersion, outcomesBlob, outcomesVersion, goalCheckpointsBlob, goalCheckpointsVersion, discussedBlob, discussedVersion, createdAt, formVersion, templateKey, oneOff, employee_id, manager_id, company_id, customTemplateVersion_id FROM __temp__anketas'); + $this->addSql('DROP TABLE __temp__anketas'); + $this->addSql('CREATE INDEX idx_anketas_employee_manager_meeting_date ON anketas (employee_id, manager_id, meetingDate)'); + $this->addSql('CREATE INDEX idx_anketas_archived_meeting_date ON anketas (archivedAt, meetingDate)'); + $this->addSql('CREATE INDEX IDX_865B0D848C03F15C ON anketas (employee_id)'); + $this->addSql('CREATE INDEX IDX_865B0D84783E3463 ON anketas (manager_id)'); + $this->addSql('CREATE INDEX IDX_865B0D84979B1AD6 ON anketas (company_id)'); + $this->addSql('CREATE INDEX IDX_865B0D84E3829923 ON anketas (customTemplateVersion_id)'); + } +} diff --git a/backend/src/Anketa/AnketaLifecycleService.php b/backend/src/Anketa/AnketaLifecycleService.php index dabfd976..38978600 100644 --- a/backend/src/Anketa/AnketaLifecycleService.php +++ b/backend/src/Anketa/AnketaLifecycleService.php @@ -28,8 +28,8 @@ public function __construct( } /** - * Builds a new Anketa (optionally seeded with a client-carried outcomesBlob) and - * copies in_progress goals from $carryFrom into it, if given — except for a one-off, + * Builds a new Anketa (optionally seeded with a client-carried outcomesBlob and + * topicsBlob) and copies in_progress goals from $carryFrom into it, if given — except for a one-off, * which never gets a carry-forward (GitHub issue #111, see Anketa::$oneOff): the * pair's open chain anketa already has it, and a second copy would just diverge. * Enforced here rather than by callers, so no caller can bring the duplicates back. @@ -44,6 +44,7 @@ public function createWithCarryForward( string $managerSealedKey, int $periodicityDays, ?string $outcomesBlob = null, + ?string $topicsBlob = null, ?Anketa $carryFrom = null, string $templateKey = Anketa::DEFAULT_TEMPLATE_KEY, bool $oneOff = false, @@ -51,6 +52,7 @@ public function createWithCarryForward( ): Anketa { if ($oneOff) { $outcomesBlob = null; + $topicsBlob = null; $carryFrom = null; } @@ -69,6 +71,9 @@ public function createWithCarryForward( if (null !== $outcomesBlob) { $anketa->seedOutcomes($outcomesBlob); } + if (null !== $topicsBlob) { + $anketa->seedTopics($topicsBlob); + } $this->entityManager->persist($anketa); @@ -101,6 +106,7 @@ public function createAnketa( string $managerSealedKey, int $periodicityDays, ?string $outcomesBlob = null, + ?string $topicsBlob = null, ?Anketa $carryFrom = null, ?User $creator = null, string $templateKey = Anketa::DEFAULT_TEMPLATE_KEY, @@ -115,6 +121,7 @@ public function createAnketa( managerSealedKey: $managerSealedKey, periodicityDays: $periodicityDays, outcomesBlob: $outcomesBlob, + topicsBlob: $topicsBlob, carryFrom: $carryFrom, templateKey: $templateKey, oneOff: $oneOff, @@ -150,6 +157,8 @@ public function createAnketa( * AnketaAlreadyArchivedException and changes nothing. * * @throws AnketaAlreadyArchivedException + * @throws AnketaTopicsChangedException if `$expectedTopicsVersion` is given and the + * topics list is no longer at it; nothing is archived */ public function archive( Anketa $anketa, @@ -160,8 +169,10 @@ public function archive( ?string $mySealedKey = null, ?string $counterpartSealedKey = null, ?string $outcomesBlob = null, + ?string $topicsBlob = null, ?string $nextTemplateKey = null, ?CustomTemplateVersion $nextCustomTemplateVersion = null, + ?int $expectedTopicsVersion = null, ): ?Anketa { $nextPeriodicityDays = $this->nextAnketaPeriodicity($anketa, $skipNextMeeting, $mySealedKey, $counterpartSealedKey); // A programming error, not a fallback: AnketaController::archive() always @@ -174,14 +185,14 @@ public function archive( // archived — same reason as InviteController::create(): wrapInTransaction() // closes the EntityManager on any exception. $nextAnketa = $this->entityManager->wrapInTransaction(function () use ( - $anketa, $actor, $missed, $nextPeriodicityDays, $nextMeetingDate, $mySealedKey, $counterpartSealedKey, $outcomesBlob, $nextTemplateKey, $nextCustomTemplateVersion, + $anketa, $actor, $missed, $nextPeriodicityDays, $nextMeetingDate, $mySealedKey, $counterpartSealedKey, $outcomesBlob, $topicsBlob, $nextTemplateKey, $nextCustomTemplateVersion, $expectedTopicsVersion, ): Anketa|false|null { $archivedAt = new \DateTimeImmutable(); // Must stay the transaction's first statement. On SQLite (WAL), a deferred // transaction that has already read and then tries to write fails at once // with SQLITE_BUSY instead of waiting out busy_timeout, so the losing // request would get a 500 rather than this clean "already archived". - if (!$this->anketaRepository->markArchivedIfOpen($anketa, $archivedAt, $missed)) { + if (!$this->anketaRepository->markArchivedIfOpen($anketa, $archivedAt, $missed, $expectedTopicsVersion)) { return false; } // Re-read rather than calling $anketa->archive(): the row is the source of @@ -205,13 +216,14 @@ public function archive( $mySealedKey, $counterpartSealedKey, $outcomesBlob, + $topicsBlob, $nextTemplateKey, $nextCustomTemplateVersion, ); }); if (false === $nextAnketa) { - throw new AnketaAlreadyArchivedException(); + throw $this->archiveRefusal($anketa, $expectedTopicsVersion); } if (null !== $nextAnketa) { @@ -288,6 +300,24 @@ public function shouldCreateNext(Anketa $anketa, bool $skipNextMeeting): bool return !$anketa->getEmployee()->isBlocked() && !$anketa->getManager()->isBlocked(); } + /** + * Why archive()'s conditional UPDATE matched nothing. With a topics version named + * it may be that the list moved on rather than that the anketa is archived, so the + * row is re-read to tell. (archive()'s transaction callback returned, so the + * EntityManager is open.). + */ + private function archiveRefusal(Anketa $anketa, ?int $expectedTopicsVersion): AnketaAlreadyArchivedException|AnketaTopicsChangedException + { + if (null !== $expectedTopicsVersion) { + $this->entityManager->refresh($anketa); + if (!$anketa->isArchived()) { + return new AnketaTopicsChangedException(); + } + } + + return new AnketaAlreadyArchivedException(); + } + /** * The successor's periodicity if archive() should create one (see * shouldCreateNext()), after checking that everything it needs for one is there; @@ -326,6 +356,7 @@ private function createNextAnketa( string $mySealedKey, string $counterpartSealedKey, ?string $outcomesBlob, + ?string $topicsBlob, string $nextTemplateKey, ?CustomTemplateVersion $nextCustomTemplateVersion, ): Anketa { @@ -342,6 +373,7 @@ private function createNextAnketa( managerSealedKey: $isEmployee ? $counterpartSealedKey : $mySealedKey, periodicityDays: $periodicityDays, outcomesBlob: $outcomesBlob, + topicsBlob: $topicsBlob, carryFrom: $anketa, templateKey: $nextTemplateKey, customTemplateVersion: $nextCustomTemplateVersion, diff --git a/backend/src/Anketa/AnketaPresenter.php b/backend/src/Anketa/AnketaPresenter.php index c485384d..e5e633be 100644 --- a/backend/src/Anketa/AnketaPresenter.php +++ b/backend/src/Anketa/AnketaPresenter.php @@ -98,7 +98,7 @@ public function summarizeForList(Anketa $anketa, User $user): array * outcomesBlob: string|null, outcomesVersion: int, goals: list, goalCheckpointsBlob: string|null, goalCheckpointsVersion: int, - * discussedBlob: string|null, discussedVersion: int, + * discussedBlob: string|null, discussedVersion: int, topicsBlob: string|null, topicsVersion: int, * nextCycleTemplateKey: string|null, nextCustomTemplateId: string|null, * customTemplateVersionId: string|null, customTemplateName: string|null} */ @@ -131,6 +131,8 @@ public function serializeDetail(Anketa $anketa, User $user, array $goals): array 'goalCheckpointsVersion' => $anketa->getGoalCheckpointsVersion(), 'discussedBlob' => $anketa->getDiscussedBlob(), 'discussedVersion' => $anketa->getDiscussedVersion(), + 'topicsBlob' => $anketa->getTopicsBlob(), + 'topicsVersion' => $anketa->getTopicsVersion(), 'nextCycleTemplateKey' => $nextTemplate['key'] ?? null, // The company template to preselect when the default is 'custom': the // template's id, so its latest version, not necessarily this anketa's. @@ -148,7 +150,7 @@ public function serializeDetail(Anketa $anketa, User $user, array $goals): array * counterpartName: string, meetingDate: string, myPublishedAt: string|null, counterpartPublishedAt: string|null, * archivedAt: string|null, missed: bool, periodicityDays: int|null, counterpartKeyOutdated: bool, * counterpartDeleted: bool, formVersion: int, employeeBlobVersion: int, managerBlobVersion: int, - * commentsVersion: int, outcomesVersion: int, goalCheckpointsVersion: int, discussedVersion: int} + * commentsVersion: int, outcomesVersion: int, goalCheckpointsVersion: int, discussedVersion: int, topicsVersion: int} */ public function serializeLiveState(Anketa $anketa, User $user): array { @@ -160,6 +162,7 @@ public function serializeLiveState(Anketa $anketa, User $user): array 'outcomesVersion' => $anketa->getOutcomesVersion(), 'goalCheckpointsVersion' => $anketa->getGoalCheckpointsVersion(), 'discussedVersion' => $anketa->getDiscussedVersion(), + 'topicsVersion' => $anketa->getTopicsVersion(), ]; // templateKey is immutable once an anketa is created, so it has nothing to poll // for — dropped explicitly rather than left in as wasted payload on every 4s diff --git a/backend/src/Anketa/AnketaTopicsChangedException.php b/backend/src/Anketa/AnketaTopicsChangedException.php new file mode 100644 index 00000000..f69db03e --- /dev/null +++ b/backend/src/Anketa/AnketaTopicsChangedException.php @@ -0,0 +1,22 @@ +}>} $fixture */ + /** @var array{generatedAt: string, password: string, locales: array}>} $fixture */ $fixture = json_decode((string) file_get_contents($fixturePath), true, flags: \JSON_THROW_ON_ERROR); $now = new \DateTimeImmutable(); @@ -107,6 +107,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int outcomesVersion: $cycle['outcomesVersion'], goalCheckpointsBlob: $cycle['goalCheckpointsBlob'], goalCheckpointsVersion: $cycle['goalCheckpointsVersion'], + topicsBlob: $cycle['topicsBlob'], + topicsVersion: $cycle['topicsVersion'], archived: $cycle['archived'], missed: $cycle['missed'], ); diff --git a/backend/src/Controller/AnketaController.php b/backend/src/Controller/AnketaController.php index 63e344a5..9cfa7c0f 100644 --- a/backend/src/Controller/AnketaController.php +++ b/backend/src/Controller/AnketaController.php @@ -5,6 +5,7 @@ use App\Anketa\AnketaAlreadyArchivedException; use App\Anketa\AnketaLifecycleService; use App\Anketa\AnketaPresenter; +use App\Anketa\AnketaTopicsChangedException; use App\Dto\ArchiveAnketaRequest; use App\Dto\CreateAnketaRequest; use App\Dto\CreateGoalRequest; @@ -142,6 +143,7 @@ public function create( periodicityDays: $periodicityDays, // Dropped for a one-off by the service itself — see createWithCarryForward(). outcomesBlob: $payload->outcomesBlob, + topicsBlob: $payload->topicsBlob, carryFrom: $previousAnketa, creator: $user, templateKey: $payload->templateKey, @@ -307,22 +309,51 @@ public function saveDiscussed( [$anketa] = $this->findAccessible($id, $request); $expectedVersion = (int) $payload->expectedVersion; - if (!$this->anketaRepository->saveDiscussedIfVersion($anketa, (string) $payload->blob, $expectedVersion)) { - // A stale version or an archived anketa: re-read which one it was. The - // archived 409 carries no blob, so the client doesn't retry it as a conflict. - $this->entityManager->refresh($anketa); - if ($anketa->isArchived()) { - throw new ConflictHttpException($this->translator->trans('errors.anketa_archived')); - } + if ($this->anketaRepository->saveDiscussedIfVersion($anketa, (string) $payload->blob, $expectedVersion)) { + return new JsonResponse(['discussedVersion' => $expectedVersion + 1]); + } + $this->refreshAfterRefusedSave($anketa); - return new JsonResponse([ - 'error' => $this->translator->trans('errors.discussed_conflict'), - 'discussedBlob' => $anketa->getDiscussedBlob(), - 'discussedVersion' => $anketa->getDiscussedVersion(), - ], 409); + return new JsonResponse([ + 'error' => $this->translator->trans('errors.discussed_conflict'), + 'discussedBlob' => $anketa->getDiscussedBlob(), + 'discussedVersion' => $anketa->getDiscussedVersion(), + ], 409); + } + + /** The shared "Topics to discuss" list (GitHub issue #206); saved like the discussed ticks. */ + #[Route('/api/anketas/{id}/topics', name: 'anketa_topics', methods: ['PUT'])] + public function saveTopics( + string $id, + #[MapRequestPayload] SaveVersionedBlobRequest $payload, + Request $request, + ): JsonResponse { + [$anketa] = $this->findAccessible($id, $request); + $expectedVersion = (int) $payload->expectedVersion; + + if ($this->anketaRepository->saveTopicsIfVersion($anketa, (string) $payload->blob, $expectedVersion)) { + return new JsonResponse(['topicsVersion' => $expectedVersion + 1]); } + $this->refreshAfterRefusedSave($anketa); - return new JsonResponse(['discussedVersion' => $expectedVersion + 1]); + return new JsonResponse([ + 'error' => $this->translator->trans('errors.topics_conflict'), + 'topicsBlob' => $anketa->getTopicsBlob(), + 'topicsVersion' => $anketa->getTopicsVersion(), + ], 409); + } + + /** + * After a conditional save matched no row: a stale version or an archived anketa. + * Re-reads which one it was, for the caller's conflict response. The archived 409 + * carries no blob, so the client doesn't retry it as a conflict. + */ + private function refreshAfterRefusedSave(Anketa $anketa): void + { + $this->entityManager->refresh($anketa); + if ($anketa->isArchived()) { + throw new ConflictHttpException($this->translator->trans('errors.anketa_archived')); + } } #[Route('/api/anketas/{id}/goals', name: 'anketa_goal_create', methods: ['POST'])] @@ -549,9 +580,21 @@ public function archive( mySealedKey: $mySealedKey, counterpartSealedKey: $counterpartSealedKey, outcomesBlob: $payload->outcomesBlob, + topicsBlob: $payload->topicsBlob, nextTemplateKey: $nextTemplateKey, nextCustomTemplateVersion: $nextCustomTemplateVersion, + // Only with a successor: nothing is carried forward otherwise. + expectedTopicsVersion: $createNext ? $payload->topicsVersion : null, ); + } catch (AnketaTopicsChangedException) { + // Still open: the topics list changed after the client built its + // carry-forward (the service has re-read it). Same body as a refused topics + // save, so the client takes the current list and archives again. + return new JsonResponse([ + 'error' => $this->translator->trans('errors.topics_conflict'), + 'topicsBlob' => $anketa->getTopicsBlob(), + 'topicsVersion' => $anketa->getTopicsVersion(), + ], 409); } catch (AnketaAlreadyArchivedException) { // This request's copy predates the archive that won; re-read it for the // response. (wrapInTransaction() didn't throw, so the EntityManager is open.) diff --git a/backend/src/Dto/ArchiveAnketaRequest.php b/backend/src/Dto/ArchiveAnketaRequest.php index ae270846..df076901 100644 --- a/backend/src/Dto/ArchiveAnketaRequest.php +++ b/backend/src/Dto/ArchiveAnketaRequest.php @@ -19,6 +19,15 @@ public function __construct( #[Assert\Type('string')] public ?string $outcomesBlob = null, #[Assert\Type('string')] + public ?string $topicsBlob = null, + // The topics version that topicsBlob was built from (GitHub issue #206). If the + // list has changed since, the archive is refused with the current list, so the + // client rebuilds the carry-forward instead of leaving a new topic behind. + // Absent (an older client) means no check. + #[Assert\Type('int')] + #[Assert\PositiveOrZero] + public ?int $topicsVersion = null, + #[Assert\Type('string')] public ?string $mySealedKey = null, #[Assert\Type('string')] public ?string $counterpartSealedKey = null, diff --git a/backend/src/Dto/CreateAnketaRequest.php b/backend/src/Dto/CreateAnketaRequest.php index b0aed52e..5e3b68c5 100644 --- a/backend/src/Dto/CreateAnketaRequest.php +++ b/backend/src/Dto/CreateAnketaRequest.php @@ -31,6 +31,9 @@ public function __construct( #[Assert\Type('string')] public ?string $outcomesBlob = null, + #[Assert\Type('string')] + public ?string $topicsBlob = null, + public string $templateKey = Anketa::DEFAULT_TEMPLATE_KEY, // The company template (GitHub issue #144), with templateKey 'custom' and only diff --git a/backend/src/Entity/Anketa.php b/backend/src/Entity/Anketa.php index 08eae6bd..d61600ce 100644 --- a/backend/src/Entity/Anketa.php +++ b/backend/src/Entity/Anketa.php @@ -37,7 +37,7 @@ class Anketa * that's a real product decision this is the simplest thing that lets * the one global form change over time without breaking old anketas. */ - public const int CURRENT_FORM_VERSION = 2; + public const int CURRENT_FORM_VERSION = 3; /** * Which built-in question-set template this anketa uses (e.g. a different @@ -289,6 +289,18 @@ public static function nextCycleTemplateKeyFor(string $templateKey): string #[ORM\Column(type: 'integer', options: ['default' => 0])] private int $discussedVersion = 0; + /** + * The shared "Topics to discuss" list (GitHub issue #206), encrypted under anketaKey: + * either participant adds topics at any time, before or after publishing. Written + * only by AnketaRepository::saveTopicsIfVersion(), like discussedBlob, apart from + * seedTopics() at creation. DB-level default for the same reason as discussedVersion's. + */ + #[ORM\Column(type: 'text', nullable: true)] + private ?string $topicsBlob = null; + + #[ORM\Column(type: 'integer', options: ['default' => 0])] + private int $topicsVersion = 0; + #[ORM\Column(type: 'datetime_immutable')] private \DateTimeImmutable $createdAt; @@ -682,6 +694,22 @@ public function getDiscussedVersion(): int return $this->discussedVersion; } + public function getTopicsBlob(): ?string + { + return $this->topicsBlob; + } + + /** Seeds the topics carried forward from the previous meeting at creation, like seedOutcomes(): topicsVersion stays 0. */ + public function seedTopics(string $blob): void + { + $this->topicsBlob = $blob; + } + + public function getTopicsVersion(): int + { + return $this->topicsVersion; + } + public function isArchived(): bool { return null !== $this->archivedAt; @@ -708,7 +736,7 @@ public function getFollowUpMeetingDay(): ?\DateTimeImmutable * (each reset deletes and recreates every demo anketa from scratch — * see the command's own docblock for why). Bypasses the normal one-way * publish()/saveComments()/saveOutcomes()/saveGoalCheckpoints() - * version-guarded mutators and archive()'s "now" timestamp entirely on + * version-guarded mutators (and the repository's conditional topics save) and archive()'s "now" timestamp entirely on * purpose — those exist to protect real concurrent edits and record a * genuine archive moment, neither of which applies to a scheduled * reset replaying fixed, already-encrypted bytes. Blob/publishedAt @@ -726,6 +754,8 @@ public function resetForDemo( int $outcomesVersion, ?string $goalCheckpointsBlob, int $goalCheckpointsVersion, + ?string $topicsBlob, + int $topicsVersion, bool $archived, bool $missed, ): void { @@ -744,5 +774,7 @@ public function resetForDemo( $this->outcomesVersion = $outcomesVersion; $this->goalCheckpointsBlob = $goalCheckpointsBlob; $this->goalCheckpointsVersion = $goalCheckpointsVersion; + $this->topicsBlob = $topicsBlob; + $this->topicsVersion = $topicsVersion; } } diff --git a/backend/src/Repository/AnketaRepository.php b/backend/src/Repository/AnketaRepository.php index 6c48d281..12660c94 100644 --- a/backend/src/Repository/AnketaRepository.php +++ b/backend/src/Repository/AnketaRepository.php @@ -274,18 +274,28 @@ private function onDay(Query $query, \DateTimeImmutable $dayStart): Query * forking the pair's chain. The database serializes the two UPDATEs (a row lock on * MySQL, the write lock plus busy_timeout on SQLite), so the second one matches * no row. + * + * With `$expectedTopicsVersion` (GitHub issue #206), also only if the topics list is + * still at that version: the archiving browser built the successor's carried-forward + * topics from it, and a topic saved since would otherwise be missing from the next + * meeting. The same statement, so no topics save can land in between. False then + * means "already archived" or "topics changed"; the caller re-reads which. */ - public function markArchivedIfOpen(Anketa $anketa, \DateTimeImmutable $archivedAt, bool $missed): bool + public function markArchivedIfOpen(Anketa $anketa, \DateTimeImmutable $archivedAt, bool $missed, ?int $expectedTopicsVersion = null): bool { - $affected = $this->getEntityManager()->createQuery( - 'UPDATE '.Anketa::class.' a SET a.archivedAt = :archivedAt, a.missed = :missed WHERE a.id = :id AND a.archivedAt IS NULL' - ) + $dql = 'UPDATE '.Anketa::class.' a SET a.archivedAt = :archivedAt, a.missed = :missed WHERE a.id = :id AND a.archivedAt IS NULL'; + if (null !== $expectedTopicsVersion) { + $dql .= ' AND a.topicsVersion = :expectedTopicsVersion'; + } + $query = $this->getEntityManager()->createQuery($dql) ->setParameter('archivedAt', $archivedAt, Types::DATETIME_IMMUTABLE) ->setParameter('missed', $missed, Types::BOOLEAN) - ->setParameter('id', $anketa->getId()) - ->execute(); + ->setParameter('id', $anketa->getId()); + if (null !== $expectedTopicsVersion) { + $query->setParameter('expectedTopicsVersion', $expectedTopicsVersion); + } - return 1 === $affected; + return 1 === $query->execute(); } /** @@ -298,10 +308,27 @@ public function markArchivedIfOpen(Anketa $anketa, \DateTimeImmutable $archivedA */ public function saveDiscussedIfVersion(Anketa $anketa, string $blob, int $expectedVersion): bool { - $affected = $this->getEntityManager()->createQuery( + return $this->saveIfVersion( 'UPDATE '.Anketa::class.' a SET a.discussedBlob = :blob, a.discussedVersion = a.discussedVersion + 1' - .' WHERE a.id = :id AND a.discussedVersion = :expectedVersion AND a.archivedAt IS NULL' - ) + .' WHERE a.id = :id AND a.discussedVersion = :expectedVersion AND a.archivedAt IS NULL', + $anketa, $blob, $expectedVersion, + ); + } + + /** The same for the shared topics list (GitHub issue #206), which both participants add to and tick off during the meeting. */ + public function saveTopicsIfVersion(Anketa $anketa, string $blob, int $expectedVersion): bool + { + return $this->saveIfVersion( + 'UPDATE '.Anketa::class.' a SET a.topicsBlob = :blob, a.topicsVersion = a.topicsVersion + 1' + .' WHERE a.id = :id AND a.topicsVersion = :expectedVersion AND a.archivedAt IS NULL', + $anketa, $blob, $expectedVersion, + ); + } + + /** Runs one of the two conditional UPDATEs above and reports whether it matched the row. */ + private function saveIfVersion(string $dql, Anketa $anketa, string $blob, int $expectedVersion): bool + { + $affected = $this->getEntityManager()->createQuery($dql) ->setParameter('blob', $blob) ->setParameter('id', $anketa->getId()) ->setParameter('expectedVersion', $expectedVersion) diff --git a/backend/tests/Architecture/SerializationBoundaryTest.php b/backend/tests/Architecture/SerializationBoundaryTest.php index 0df4f262..e49ff069 100644 --- a/backend/tests/Architecture/SerializationBoundaryTest.php +++ b/backend/tests/Architecture/SerializationBoundaryTest.php @@ -48,6 +48,7 @@ public static function ciphertextBearingAnketaProperties(): array 'outcomesBlob' => ['outcomesBlob'], 'goalCheckpointsBlob' => ['goalCheckpointsBlob'], 'discussedBlob' => ['discussedBlob'], + 'topicsBlob' => ['topicsBlob'], ]; } diff --git a/backend/tests/Functional/AnketaControllerTest.php b/backend/tests/Functional/AnketaControllerTest.php index be3a0c4c..0b33a45b 100644 --- a/backend/tests/Functional/AnketaControllerTest.php +++ b/backend/tests/Functional/AnketaControllerTest.php @@ -428,6 +428,223 @@ public function testSaveDiscussedArchivedReturns409(): void self::assertArrayNotHasKey('discussedVersion', $result['json']); } + public function testSaveTopicsSucceedsAndIncrementsVersion(): void + { + [$employeeClient, , $managerClient, $manager] = $this->makePair('topics-ok'); + $anketaId = $this->createAnketaAsEmployee($employeeClient, $manager['id'])['json']['id']; + + $fresh = $this->jsonRequest($employeeClient, 'GET', "/api/anketas/{$anketaId}")['json']; + self::assertNull($fresh['topicsBlob']); + self::assertSame(0, $fresh['topicsVersion']); + + // Neither side has published: the list is shared from the start. + $result = $this->jsonRequest($employeeClient, 'PUT', "/api/anketas/{$anketaId}/topics", [ + 'blob' => 'topics-blob-v1', + 'expectedVersion' => 0, + ]); + + self::assertSame(200, $result['status']); + self::assertSame(1, $result['json']['topicsVersion']); + + $detail = $this->jsonRequest($managerClient, 'GET', "/api/anketas/{$anketaId}")['json']; + self::assertSame('topics-blob-v1', $detail['topicsBlob']); + self::assertSame(1, $detail['topicsVersion']); + self::assertSame(0, $detail['discussedVersion'], 'the discussed ticks have their own version'); + self::assertSame(0, $detail['outcomesVersion']); + + $liveState = $this->jsonRequest($managerClient, 'GET', "/api/anketas/{$anketaId}/live-state")['json']; + self::assertSame(1, $liveState['topicsVersion']); + self::assertArrayNotHasKey('topicsBlob', $liveState); + + $second = $this->jsonRequest($managerClient, 'PUT', "/api/anketas/{$anketaId}/topics", [ + 'blob' => 'topics-blob-v2', + 'expectedVersion' => 1, + ]); + self::assertSame(200, $second['status']); + self::assertSame(2, $second['json']['topicsVersion']); + } + + public function testSaveTopicsConflictReturns409WithTheCurrentList(): void + { + [$employeeClient, , $managerClient, $manager] = $this->makePair('topics-conflict'); + $anketaId = $this->createAnketaAsEmployee($employeeClient, $manager['id'])['json']['id']; + + $first = $this->jsonRequest($managerClient, 'PUT', "/api/anketas/{$anketaId}/topics", [ + 'blob' => 'manager-topics', + 'expectedVersion' => 0, + ]); + self::assertSame(200, $first['status']); + + $result = $this->jsonRequest($employeeClient, 'PUT', "/api/anketas/{$anketaId}/topics", [ + 'blob' => 'employee-topics', + 'expectedVersion' => 0, + ]); + + self::assertSame(409, $result['status']); + self::assertSame('manager-topics', $result['json']['topicsBlob']); + self::assertSame(1, $result['json']['topicsVersion']); + self::assertArrayHasKey('error', $result['json']); + + $detail = $this->jsonRequest($employeeClient, 'GET', "/api/anketas/{$anketaId}")['json']; + self::assertSame('manager-topics', $detail['topicsBlob'], 'the refused save must not overwrite the list'); + } + + public function testSaveTopicsArchivedReturns409(): void + { + [$employeeClient, , , $manager] = $this->makePair('topics-archived'); + $anketaId = $this->createAnketaAsEmployee($employeeClient, $manager['id'])['json']['id']; + + $this->jsonRequest($employeeClient, 'POST', "/api/anketas/{$anketaId}/archive", [ + 'missed' => false, + 'skipNextMeeting' => true, + ]); + + $result = $this->jsonRequest($employeeClient, 'PUT', "/api/anketas/{$anketaId}/topics", [ + 'blob' => 'employee-topics', + 'expectedVersion' => 0, + ]); + + self::assertSame(409, $result['status']); + // No blob/version in the body, so the client doesn't retry it as a stale version. + self::assertArrayNotHasKey('topicsVersion', $result['json']); + self::assertNull($this->jsonRequest($employeeClient, 'GET', "/api/anketas/{$anketaId}")['json']['topicsBlob']); + } + + public function testSaveTopicsRejectsAMalformedPayloadAndAnOutsider(): void + { + [$employeeClient, , , $manager] = $this->makePair('topics-invalid'); + $anketaId = $this->createAnketaAsEmployee($employeeClient, $manager['id'])['json']['id']; + + self::assertSame(400, $this->jsonRequest($employeeClient, 'PUT', "/api/anketas/{$anketaId}/topics", [ + 'blob' => 'topics', + ])['status']); + self::assertSame(400, $this->jsonRequest($employeeClient, 'PUT', "/api/anketas/{$anketaId}/topics", [ + 'expectedVersion' => 0, + ])['status']); + + $stranger = $this->secondClient(); + $this->activateUser($stranger, $this->uniqueEmail('anketa-topics-stranger')); + $outsider = $this->jsonRequest($stranger, 'PUT', "/api/anketas/{$anketaId}/topics", [ + 'blob' => 'not-mine', + 'expectedVersion' => 0, + ]); + self::assertSame(403, $outsider['status']); + self::assertNull($this->jsonRequest($employeeClient, 'GET', "/api/anketas/{$anketaId}")['json']['topicsBlob']); + } + + /** + * GitHub issue #206: the client re-encrypts the topics not yet discussed under the + * next meeting's key and sends them along with the archive (or create) request. + */ + public function testArchiveSeedsTheSuccessorWithTheCarriedTopics(): void + { + [$employeeClient, , , $manager] = $this->makePair('topics-carry'); + $firstId = $this->createAnketaAsEmployee($employeeClient, $manager['id'])['json']['id']; + + $archive = $this->jsonRequest($employeeClient, 'POST', "/api/anketas/{$firstId}/archive", [ + 'missed' => false, + 'skipNextMeeting' => false, + 'mySealedKey' => str_repeat('n', 44), + 'counterpartSealedKey' => str_repeat('o', 44), + 'topicsBlob' => 'carried-topics', + ]); + self::assertSame(200, $archive['status']); + + $openIds = $this->openAnketaIds($employeeClient); + self::assertCount(1, $openIds); + $next = $this->jsonRequest($employeeClient, 'GET', "/api/anketas/{$openIds[0]}")['json']; + self::assertSame('carried-topics', $next['topicsBlob']); + self::assertSame(0, $next['topicsVersion'], 'seeded at creation, like outcomes: the first save expects version 0'); + self::assertNull($next['outcomesBlob']); + + $archived = $this->jsonRequest($employeeClient, 'GET', "/api/anketas/{$firstId}")['json']; + self::assertNull($archived['topicsBlob'], 'the archived meeting keeps its own list, untouched'); + } + + /** + * The carry-forward is built in the browser from the list at some version. A topic + * saved after that would be left behind, so the archive names the version and is + * refused, with the anketa still open, if the list has moved on. + */ + public function testArchiveIsRefusedWhenTheTopicsChangedSinceTheCarryForwardWasBuilt(): void + { + [$employeeClient, , $managerClient, $manager] = $this->makePair('topics-stale-carry'); + $anketaId = $this->createAnketaAsEmployee($employeeClient, $manager['id'])['json']['id']; + $archiveBody = [ + 'missed' => false, + 'skipNextMeeting' => false, + 'mySealedKey' => str_repeat('n', 44), + 'counterpartSealedKey' => str_repeat('o', 44), + 'topicsBlob' => 'carried-from-version-0', + 'topicsVersion' => 0, + ]; + + // The counterpart adds a topic after the archiving client read version 0. + $added = $this->jsonRequest($managerClient, 'PUT', "/api/anketas/{$anketaId}/topics", [ + 'blob' => 'manager-topics', + 'expectedVersion' => 0, + ]); + self::assertSame(200, $added['status']); + + $refused = $this->jsonRequest($employeeClient, 'POST', "/api/anketas/{$anketaId}/archive", $archiveBody); + self::assertSame(409, $refused['status']); + self::assertSame('manager-topics', $refused['json']['topicsBlob']); + self::assertSame(1, $refused['json']['topicsVersion']); + self::assertArrayNotHasKey('archivedAt', $refused['json']); + + $detail = $this->jsonRequest($employeeClient, 'GET', "/api/anketas/{$anketaId}")['json']; + self::assertNull($detail['archivedAt'], 'a refused archive leaves the anketa open'); + self::assertCount(1, $this->jsonRequest($employeeClient, 'GET', '/api/anketas')['json'], 'and creates no successor'); + + // Rebuilt from version 1, it goes through. + $archived = $this->jsonRequest($employeeClient, 'POST', "/api/anketas/{$anketaId}/archive", [ + ...$archiveBody, + 'topicsBlob' => 'carried-from-version-1', + 'topicsVersion' => 1, + ]); + self::assertSame(200, $archived['status']); + $openIds = $this->openAnketaIds($employeeClient); + self::assertCount(1, $openIds); + self::assertSame('carried-from-version-1', $this->jsonRequest($employeeClient, 'GET', "/api/anketas/{$openIds[0]}")['json']['topicsBlob']); + + // Archived now: the same request again is the ordinary "already archived" 409. + $again = $this->jsonRequest($employeeClient, 'POST', "/api/anketas/{$anketaId}/archive", $archiveBody); + self::assertSame(409, $again['status']); + self::assertArrayHasKey('archivedAt', $again['json']); + self::assertArrayNotHasKey('topicsVersion', $again['json']); + } + + public function testArchiveWithoutASuccessorIgnoresTheTopicsVersion(): void + { + [$employeeClient, , , $manager] = $this->makePair('topics-version-skip'); + $anketaId = $this->createAnketaAsEmployee($employeeClient, $manager['id'])['json']['id']; + $this->jsonRequest($employeeClient, 'PUT', "/api/anketas/{$anketaId}/topics", [ + 'blob' => 'topics', + 'expectedVersion' => 0, + ]); + + // Nothing is carried forward, so a stale version is no reason to refuse. + $archived = $this->jsonRequest($employeeClient, 'POST', "/api/anketas/{$anketaId}/archive", [ + 'missed' => false, + 'skipNextMeeting' => true, + 'topicsVersion' => 0, + ]); + + self::assertSame(200, $archived['status']); + } + + public function testCreateSeedsTheCarriedTopics(): void + { + [$employeeClient, , , $manager] = $this->makePair('topics-create'); + + $created = $this->createAnketaAsEmployee($employeeClient, $manager['id'], ['topicsBlob' => 'carried-topics']); + self::assertSame(201, $created['status']); + + $detail = $this->jsonRequest($employeeClient, 'GET', "/api/anketas/{$created['json']['id']}")['json']; + self::assertSame('carried-topics', $detail['topicsBlob']); + self::assertSame(0, $detail['topicsVersion']); + } + public function testTheLiveStatePollAloneKeepsASessionAlive(): void { $clock = self::mockTime(); @@ -467,6 +684,7 @@ public function testLiveStateReturnsCurrentScalarsAndVersions(): void self::assertSame(1, $result['json']['outcomesVersion']); self::assertSame(0, $result['json']['goalCheckpointsVersion']); self::assertSame(0, $result['json']['discussedVersion']); + self::assertSame(0, $result['json']['topicsVersion']); self::assertSame(0, $result['json']['employeeBlobVersion']); self::assertSame(0, $result['json']['managerBlobVersion']); self::assertNull($result['json']['myPublishedAt']); @@ -1337,6 +1555,7 @@ public function testAHandCreatedAnketaNextToAnOpenOneIsAOneOff(): void $second = $this->createAnketaAsEmployee($employeeClient, $manager['id'], [ 'templateKey' => 'career_growth', 'outcomesBlob' => 'client-carried-outcomes', + 'topicsBlob' => 'client-carried-topics', 'periodicityDays' => 7, ]); self::assertSame(201, $second['status']); @@ -1346,6 +1565,7 @@ public function testAHandCreatedAnketaNextToAnOpenOneIsAOneOff(): void self::assertTrue($secondDetail['oneOff']); self::assertSame([], $secondDetail['goals'], 'the already-open anketa got the carry-forward; this one must not duplicate it'); self::assertNull($secondDetail['outcomesBlob'], 'a client-carried outcomesBlob must be dropped for the same reason'); + self::assertNull($secondDetail['topicsBlob'], 'and so must a client-carried topicsBlob'); self::assertSame(30, $secondDetail['periodicityDays'], 'periodicity is inherited, a sent one ignored'); // Archived with the form's defaults (skipNextMeeting unchecked, keys sent) — the diff --git a/backend/tests/Functional/PrivacyBlackBoxTest.php b/backend/tests/Functional/PrivacyBlackBoxTest.php index a7a2996e..622b4f45 100644 --- a/backend/tests/Functional/PrivacyBlackBoxTest.php +++ b/backend/tests/Functional/PrivacyBlackBoxTest.php @@ -79,6 +79,7 @@ public function testOnlyTheCorrectPrivateKeyCanRecoverThePublishedPlaintext(): v self::assertSame($scenario['encryptedMarkers']['outcomes'], $this->decryptBlob($detail['outcomesBlob'], $anketaKey)); self::assertSame($scenario['encryptedMarkers']['checkpoint'], $this->decryptBlob($detail['goalCheckpointsBlob'], $anketaKey)); self::assertSame($scenario['encryptedMarkers']['discussed'], $this->decryptBlob($detail['discussedBlob'], $anketaKey)); + self::assertSame($scenario['encryptedMarkers']['topics'], $this->decryptBlob($detail['topicsBlob'], $anketaKey)); } public function testDecryptingWithTheWrongKeyFails(): void @@ -150,6 +151,7 @@ private function buildFullyPopulatedAnketa(): array 'outcomes' => $marker('outcomes'), 'checkpoint' => $marker('checkpoint'), 'discussed' => $marker('discussed'), + 'topics' => $marker('topics'), ]; $goalTitleMarker = $marker('goal-title'); @@ -181,6 +183,13 @@ private function buildFullyPopulatedAnketa(): array ]); self::assertSame(200, $discussed['status']); + // Before either side publishes in a real meeting; the order makes no difference here. + $topics = $this->jsonRequest($managerClient, 'PUT', "/api/anketas/{$anketaId}/topics", [ + 'blob' => $this->encryptBlob($encryptedMarkers['topics'], $anketaKeyRaw), + 'expectedVersion' => 0, + ]); + self::assertSame(200, $topics['status']); + $goal = $this->jsonRequest($employeeClient, 'POST', "/api/anketas/{$anketaId}/goals", [ 'goalUuid' => "privacy-{$label}-goal", 'title' => $goalTitleMarker, diff --git a/backend/tests/Functional/Repository/AnketaRepositoryTest.php b/backend/tests/Functional/Repository/AnketaRepositoryTest.php index 4b386bc3..7f80c77d 100644 --- a/backend/tests/Functional/Repository/AnketaRepositoryTest.php +++ b/backend/tests/Functional/Repository/AnketaRepositoryTest.php @@ -289,6 +289,99 @@ public function testSaveDiscussedIfVersionOnlySucceedsOnceForAVersion(): void self::assertSame(2, $reloaded->getDiscussedVersion()); } + /** + * GitHub issue #206: the topics list is saved the same way, and on its own version: + * a topics save neither needs nor moves the discussed ticks' version. + */ + public function testSaveTopicsIfVersionOnlySucceedsOnceForAVersionAndLeavesDiscussedAlone(): void + { + [$empClient, , , $manager] = $this->makePair('topics-once'); + $anketaId = $this->createAnketaAsEmployee($empClient, $manager['id'])['json']['id']; + + $em = $this->entityManager(); + $anketaRepo = $em->getRepository(Anketa::class); + $anketa = $anketaRepo->find($anketaId); + self::assertNotNull($anketa); + + self::assertTrue($anketaRepo->saveDiscussedIfVersion($anketa, 'ticks', 0)); + self::assertTrue($anketaRepo->saveTopicsIfVersion($anketa, 'first', 0)); + self::assertFalse($anketaRepo->saveTopicsIfVersion($anketa, 'second', 0)); + self::assertTrue($anketaRepo->saveTopicsIfVersion($anketa, 'third', 1)); + + $em->clear(); + $reloaded = $anketaRepo->find($anketaId); + self::assertNotNull($reloaded); + self::assertSame('third', $reloaded->getTopicsBlob()); + self::assertSame(2, $reloaded->getTopicsVersion()); + self::assertSame('ticks', $reloaded->getDiscussedBlob()); + self::assertSame(1, $reloaded->getDiscussedVersion()); + } + + public function testSaveTopicsIfVersionRefusesAnArchivedAnketa(): void + { + [$empClient, , , $manager] = $this->makePair('topics-archived'); + $anketaId = $this->createAnketaAsEmployee($empClient, $manager['id'])['json']['id']; + + $em = $this->entityManager(); + $anketaRepo = $em->getRepository(Anketa::class); + $anketa = $anketaRepo->find($anketaId); + self::assertNotNull($anketa); + + // The stale in-memory copy a request loaded just before the archive landed. + self::assertTrue($anketaRepo->markArchivedIfOpen($anketa, new \DateTimeImmutable('2026-09-01 10:00:00'), false)); + self::assertFalse($anketaRepo->saveTopicsIfVersion($anketa, 'late', 0)); + + $em->clear(); + $reloaded = $anketaRepo->find($anketaId); + self::assertNotNull($reloaded); + self::assertNull($reloaded->getTopicsBlob()); + self::assertSame(0, $reloaded->getTopicsVersion()); + } + + public function testMarkArchivedIfOpenWithATopicsVersionOnlyMatchesThatVersion(): void + { + [$empClient, , , $manager] = $this->makePair('archive-topics-version'); + $anketaId = $this->createAnketaAsEmployee($empClient, $manager['id'])['json']['id']; + + $em = $this->entityManager(); + $anketaRepo = $em->getRepository(Anketa::class); + $anketa = $anketaRepo->find($anketaId); + self::assertNotNull($anketa); + self::assertTrue($anketaRepo->saveTopicsIfVersion($anketa, 'topics', 0)); + + $archivedAt = new \DateTimeImmutable('2026-09-01 10:00:00'); + self::assertFalse($anketaRepo->markArchivedIfOpen($anketa, $archivedAt, false, 0)); + $em->clear(); + $stillOpen = $anketaRepo->find($anketaId); + self::assertNotNull($stillOpen); + self::assertFalse($stillOpen->isArchived()); + + self::assertTrue($anketaRepo->markArchivedIfOpen($stillOpen, $archivedAt, false, 1)); + $em->clear(); + self::assertTrue($anketaRepo->find($anketaId)?->isArchived()); + } + + public function testSaveTopicsIfVersionTouchesOnlyItsOwnAnketa(): void + { + [$empClient, , , $manager] = $this->makePair('topics-own-row'); + $anketaId = $this->createAnketaAsEmployee($empClient, $manager['id'])['json']['id']; + // The same pair's second open meeting (a one-off). + $otherId = $this->createAnketaAsEmployee($empClient, $manager['id'])['json']['id']; + + $em = $this->entityManager(); + $anketaRepo = $em->getRepository(Anketa::class); + $anketa = $anketaRepo->find($anketaId); + self::assertNotNull($anketa); + + self::assertTrue($anketaRepo->saveTopicsIfVersion($anketa, 'mine', 0)); + + $em->clear(); + $other = $anketaRepo->find($otherId); + self::assertNotNull($other); + self::assertNull($other->getTopicsBlob()); + self::assertSame(0, $other->getTopicsVersion()); + } + public function testSaveDiscussedIfVersionRefusesAnArchivedAnketa(): void { [$empClient, , , $manager] = $this->makePair('discussed-archived'); diff --git a/backend/tests/Functional/ResetDemoDataCommandTest.php b/backend/tests/Functional/ResetDemoDataCommandTest.php index dcd9f3a7..4fff2061 100644 --- a/backend/tests/Functional/ResetDemoDataCommandTest.php +++ b/backend/tests/Functional/ResetDemoDataCommandTest.php @@ -80,6 +80,13 @@ public function testFirstRunCreatesEveryLocalePairWithA3CycleHistory(): void // The current cycle is deliberately left unfilled — see // ResetDemoDataCommand's own docblock. self::assertNull($anketa->getEmployeeBlob()); + // What it does have: the topic the pair didn't get to last + // time, carried forward (GitHub issue #206). + self::assertNotNull($anketa->getTopicsBlob(), "carried topic for locale \"{$localeCode}\""); + } + if ($anketa->isArchived()) { + self::assertNotNull($anketa->getTopicsBlob()); + self::assertGreaterThan(0, $anketa->getTopicsVersion()); } } self::assertSame(2, $archivedCount); diff --git a/backend/tests/Unit/Anketa/AnketaLifecycleServiceTest.php b/backend/tests/Unit/Anketa/AnketaLifecycleServiceTest.php index a0d939f1..c9928dc7 100644 --- a/backend/tests/Unit/Anketa/AnketaLifecycleServiceTest.php +++ b/backend/tests/Unit/Anketa/AnketaLifecycleServiceTest.php @@ -106,6 +106,7 @@ public function testCreateWithCarryForwardCopiesInProgressGoals(): void managerSealedKey: 'new-mgr', periodicityDays: 14, outcomesBlob: 'initial-outcomes', + topicsBlob: 'initial-topics', carryFrom: $previousAnketa, ); @@ -114,6 +115,8 @@ public function testCreateWithCarryForwardCopiesInProgressGoals(): void self::assertSame('carried-uuid', $persistedObjects[1]->getGoalUuid()); self::assertSame('Carry me', $persistedObjects[1]->getTitle()); self::assertSame('initial-outcomes', $anketa->getOutcomesBlob()); + self::assertSame('initial-topics', $anketa->getTopicsBlob()); + self::assertSame(0, $anketa->getTopicsVersion()); self::assertSame('regular', $anketa->getTemplateKey()); } @@ -644,12 +647,14 @@ public function testCreateWithCarryForwardIgnoresCarryForwardForAOneOff(): void managerSealedKey: 'new-mgr', periodicityDays: 14, outcomesBlob: 'client-carried-outcomes', + topicsBlob: 'client-carried-topics', carryFrom: $previousAnketa, oneOff: true, ); self::assertTrue($anketa->isOneOff()); self::assertNull($anketa->getOutcomesBlob()); + self::assertNull($anketa->getTopicsBlob()); } public function testCreateWithCarryForwardPassesOneOffThrough(): void diff --git a/backend/tests/Unit/Anketa/AnketaPresenterTest.php b/backend/tests/Unit/Anketa/AnketaPresenterTest.php index 836c74dc..9a8e5c90 100644 --- a/backend/tests/Unit/Anketa/AnketaPresenterTest.php +++ b/backend/tests/Unit/Anketa/AnketaPresenterTest.php @@ -243,6 +243,7 @@ public function testSerializeDetailIncludesBlobsAndGoals(): void periodicityDays: 14, ); $anketa->seedOutcomes('outcomes-blob'); + $anketa->seedTopics('topics-blob'); $goal = new Goal( goalUuid: 'uuid-456', @@ -263,6 +264,8 @@ public function testSerializeDetailIncludesBlobsAndGoals(): void self::assertSame(0, $detail['employeeBlobVersion']); self::assertSame('outcomes-blob', $detail['outcomesBlob']); self::assertSame(0, $detail['outcomesVersion']); + self::assertSame('topics-blob', $detail['topicsBlob']); + self::assertSame(0, $detail['topicsVersion']); self::assertCount(1, $detail['goals']); self::assertSame('uuid-456', $detail['goals'][0]['goalUuid']); } @@ -289,7 +292,9 @@ public function testSerializeLiveStateReturnsCountersWithoutBlobs(): void self::assertSame(0, $liveState['goalCheckpointsVersion']); self::assertSame(0, $liveState['discussedVersion']); self::assertArrayNotHasKey('commentsBlob', $liveState); + self::assertSame(0, $liveState['topicsVersion']); self::assertArrayNotHasKey('discussedBlob', $liveState); + self::assertArrayNotHasKey('topicsBlob', $liveState); self::assertArrayNotHasKey('mySealedKey', $liveState); // Immutable once created, so it has nothing to poll for — explicitly stripped // back out in serializeLiveState() even though summarize() (which this spreads) diff --git a/backend/tests/Unit/TranslationConsistencyTest.php b/backend/tests/Unit/TranslationConsistencyTest.php index 39e53994..dbc9dd45 100644 --- a/backend/tests/Unit/TranslationConsistencyTest.php +++ b/backend/tests/Unit/TranslationConsistencyTest.php @@ -3,6 +3,7 @@ namespace App\Tests\Unit; use App\Anketa\AnketaAlreadyArchivedException; +use App\Anketa\AnketaTopicsChangedException; use App\Entity\User; use PHPUnit\Framework\TestCase; use Symfony\Component\Yaml\Yaml; @@ -116,6 +117,7 @@ public function testAlreadyArchivedExceptionMatchesEnglishTranslation(): void $translations = $this->loadAllTranslations(); self::assertSame($translations['en']['errors']['anketa_archived'], (new AnketaAlreadyArchivedException())->getMessage()); + self::assertSame($translations['en']['errors']['topics_conflict'], (new AnketaTopicsChangedException())->getMessage()); } /** diff --git a/backend/translations/messages.de.yaml b/backend/translations/messages.de.yaml index 900909cf..e18e13df 100644 --- a/backend/translations/messages.de.yaml +++ b/backend/translations/messages.de.yaml @@ -99,6 +99,7 @@ errors: outcomes_conflict: 'Die Ergebnisse haben sich geändert, seit Sie sie zuletzt gelesen haben.' goal_checkpoints_conflict: 'Die Ziel-Zwischenstände haben sich geändert, seit Sie sie zuletzt gelesen haben.' discussed_conflict: 'Die besprochenen Fragen haben sich geändert, seit Sie sie zuletzt gelesen haben.' + topics_conflict: 'Die Themen haben sich geändert, seit Sie sie zuletzt gelesen haben.' private_notes_conflict: 'Ihre privaten Notizen haben sich geändert, seit Sie sie zuletzt gelesen haben.' private_notes_too_large: 'Die privaten Notizen sind zu lang zum Speichern.' private_notes_key_invalid: '"encryptedNotesKey" ist kein gültiger verschlüsselter Schlüssel.' diff --git a/backend/translations/messages.en.yaml b/backend/translations/messages.en.yaml index a991f965..db603e2e 100644 --- a/backend/translations/messages.en.yaml +++ b/backend/translations/messages.en.yaml @@ -102,6 +102,7 @@ errors: outcomes_conflict: 'Outcomes changed since you last read them.' goal_checkpoints_conflict: 'Goal checkpoints changed since you last read them.' discussed_conflict: 'Discussed questions changed since you last read them.' + topics_conflict: 'Topics changed since you last read them.' private_notes_conflict: 'Your private notes changed since you last read them.' private_notes_too_large: 'Private notes are too long to save.' private_notes_key_invalid: '"encryptedNotesKey" is not a valid encrypted key.' diff --git a/backend/translations/messages.es.yaml b/backend/translations/messages.es.yaml index 11dd8ec0..eaa7e6f4 100644 --- a/backend/translations/messages.es.yaml +++ b/backend/translations/messages.es.yaml @@ -95,6 +95,7 @@ errors: outcomes_conflict: 'Los resultados de la reunión han cambiado desde la última vez que los leíste.' goal_checkpoints_conflict: 'Los avances de los objetivos han cambiado desde la última vez que los leíste.' discussed_conflict: 'Las preguntas tratadas han cambiado desde la última vez que las leíste.' + topics_conflict: 'Los temas han cambiado desde la última vez que los leíste.' private_notes_conflict: 'Tus notas privadas han cambiado desde la última vez que las leíste.' private_notes_too_large: 'Las notas privadas son demasiado largas para guardarlas.' private_notes_key_invalid: '"encryptedNotesKey" no es una clave cifrada válida.' diff --git a/backend/translations/messages.fr.yaml b/backend/translations/messages.fr.yaml index 5315ba50..b3efb5b8 100644 --- a/backend/translations/messages.fr.yaml +++ b/backend/translations/messages.fr.yaml @@ -99,6 +99,7 @@ errors: outcomes_conflict: 'Les résultats ont changé depuis votre dernière lecture.' goal_checkpoints_conflict: 'Les jalons de l''objectif ont changé depuis votre dernière lecture.' discussed_conflict: 'Les questions abordées ont changé depuis votre dernière lecture.' + topics_conflict: 'Les sujets ont changé depuis votre dernière lecture.' private_notes_conflict: 'Vos notes privées ont changé depuis votre dernière lecture.' private_notes_too_large: 'Les notes privées sont trop longues pour être enregistrées.' private_notes_key_invalid: '"encryptedNotesKey" n''est pas une clé chiffrée valide.' diff --git a/backend/translations/messages.lv.yaml b/backend/translations/messages.lv.yaml index 8a6b791b..6a94daee 100644 --- a/backend/translations/messages.lv.yaml +++ b/backend/translations/messages.lv.yaml @@ -95,6 +95,7 @@ errors: outcomes_conflict: 'Tikšanās rezultāti ir mainījušies kopš pēdējās nolasīšanas.' goal_checkpoints_conflict: 'Mērķu kontrolpunkti ir mainījušies kopš pēdējās nolasīšanas.' discussed_conflict: 'Apspriestie jautājumi ir mainījušies kopš pēdējās nolasīšanas.' + topics_conflict: 'Tēmas ir mainījušās kopš pēdējās nolasīšanas.' private_notes_conflict: 'Jūsu privātās piezīmes ir mainījušās kopš pēdējās nolasīšanas.' private_notes_too_large: 'Privātās piezīmes ir pārāk garas, lai tās saglabātu.' private_notes_key_invalid: '"encryptedNotesKey" nav derīga šifrēta atslēga.' diff --git a/backend/translations/messages.ru.yaml b/backend/translations/messages.ru.yaml index bf73ccef..d37eec25 100644 --- a/backend/translations/messages.ru.yaml +++ b/backend/translations/messages.ru.yaml @@ -95,6 +95,7 @@ errors: outcomes_conflict: 'Итоги встречи изменились с момента вашего последнего чтения.' goal_checkpoints_conflict: 'Чекпоинты целей изменились с момента вашего последнего чтения.' discussed_conflict: 'Обсуждённые вопросы изменились с момента вашего последнего чтения.' + topics_conflict: 'Темы изменились с момента вашего последнего чтения.' private_notes_conflict: 'Ваши личные заметки изменились с момента вашего последнего чтения.' private_notes_too_large: 'Личные заметки слишком длинные для сохранения.' private_notes_key_invalid: '"encryptedNotesKey" не является корректным зашифрованным ключом.' diff --git a/docs/decisions/2026-10-04-shared-topics-list.md b/docs/decisions/2026-10-04-shared-topics-list.md new file mode 100644 index 00000000..8cd6f566 --- /dev/null +++ b/docs/decisions/2026-10-04-shared-topics-list.md @@ -0,0 +1,204 @@ +# A shared "Topics to discuss" list + +Closes [GitHub issue #206](https://github.com/aleksejs1/encrypted1on1/issues/206), the last part of +the product-adoption work ([#213](https://github.com/aleksejs1/encrypted1on1/issues/213)). + +## Problem + +Answers are private until a side publishes them. So the topics one person jots down during the +week are invisible to the other until "Publish": at the start of the meeting a manager may see +nothing of what the employee wants to talk about. A shared document doesn't have this problem, +and it is what this product competes with. + +Showing part of a draft before it is published would break the "drafts are private" model. A +list that both sides edit at any time, encrypted under the meeting key and live-synced, already +exists: outcomes. The topics list is a second one of those. + +## Decision + +- **Storage.** `Anketa::$topicsBlob` and `$topicsVersion` (SQLite and MySQL migrations), a list of + `TopicItem` (`id`, `authorId`, `text`, `discussed`, `createdAt`) encrypted under the meeting + key, in the same envelope as outcomes. It is in the detail payload, the bulk payload the data + export reads, and `topicsVersion` is in the live-state poll. +- **Saving.** `PUT /api/anketas/{id}/topics` writes through + `AnketaRepository::saveTopicsIfVersion()`: one conditional `UPDATE … WHERE topicsVersion = + :expected AND archivedAt IS NULL`, the same statement as the "discussed" ticks (#168), each + written out as its own literal query. A stale version gets a 409 with the current blob and + version; an archived meeting gets a 409 without them. +- **No draft phase.** Either side adds a topic at any time, before or after publishing, and the + other side sees it within one poll. The card says so, because it is the one place on the page + where typing something shares it at once. +- **Who changes what.** Only the author edits or deletes a topic, like an outcome. Either side + ticks a topic as discussed, unlike an outcome's "done", which only its author ticks: a topic + is talked through by both people, and the meeting usually runs on one person's screen. As + everywhere else, the client enforces this, not the server, which can't read the list. +- **Frozen once archived.** The conditional `UPDATE` refuses the save, and the card drops its + add form and buttons. Outcomes stay editable after archive; topics are an agenda, and an + agenda for a closed meeting has no use. +- **Carry-forward.** On archive, and when a meeting is created by hand after the pair's last one + was archived, the topics not ticked as discussed are re-encrypted under the new meeting's key + by the browser and sent as `topicsBlob`, exactly like unchecked outcomes + (`carryForwardBlob()` in `blobSync.ts` is now the one implementation for both). A one-off + meeting gets none and passes none on, like every other carry-forward + (`AnketaLifecycleService::createWithCarryForward()`). +- **Placement.** The first card under the meeting header, above both sides' answers. An archived + meeting with no topics shows no card, so meetings from before this feature don't gain an + empty one. +- **The duplicate questions go away, for new meetings only.** `Anketa::CURRENT_FORM_VERSION` is + now 3. From that version on `getQuestionsForSide()` leaves out `discuss` and `managerDiscuss` + for every built-in template; this is done in that one function, so all four templates are + version-aware without each needing its own rule. A meeting created at version 1 or 2 keeps + the blocks and what was answered in them. +- **Company templates keep them.** `discuss` and `managerDiscuss` stay in the allowed built-in + question ids on both sides of the lockstep validator, because every stored definition is + checked against that list: removing them would stop meetings on such a template from opening, + stop the template from being re-saved, and make import reject it. A company template that + lists them shows them at any form version (`questionsFromDefinition()` doesn't go through + `getQuestionsForSide()`). A new template's Regular prefill no longer includes them + (`RETIRED_BUILTIN_QUESTION_IDS`), so it matches what a new Regular 1:1 looks like. The + editor's "add question" menu still offers them: hiding them there, which the issue allowed + "at most", was tried and dropped in review, because an admin who removed the block from an + existing template could not put it back. The choice stays with the admin. +- **Demo data.** The demo meetings are created at the current form version, so the seeded + "what else" lines moved into the topics list: `demo-seed.json` was regenerated for all six + locales with `topicsBlob`/`topicsVersion` per cycle, which `app:reset-demo-data` restores. + The current cycle starts with one topic carried forward. `generate-doc-screenshots.mjs` + adds its two such lines as topics too. + +## The issue's open questions + +- **Comments on topics: not in this version.** A topic is one line and the conversation about + it is the meeting. Leaving them out also keeps the card out of the page-wide comment-thread + busy gate, so nothing else on the page can hold back a topics refresh. +- **Topics and the per-block "discussed" ticks (#168) are independent.** Both are meeting aids + with the same look (a tick, a dimmed line), but they are separate lists with separate + versions. A topic's tick is a field of the topic, in `topicsBlob`; a question block's tick is + in `discussedBlob`. Nothing derives one from the other. + +## Client design + +The card is `frontend/src/anketa/AnketaTopics.svelte`; the list operations are pure functions in +`topics.ts` (`addTopic`, `editTopic`, `deleteTopic`, `setTopicDiscussed`); saving is +`topicsSync.ts`. + +- **One `TopicsSync` per loaded meeting**, created by `load()` once the key and list are known + and bound to that meeting's id and key, like `DiscussedSync` (#168). The card isn't rendered + until it exists, so nothing typed there can be saved to another meeting or under another key. + Its states are idle, saving (maybe with more changes queued) and stopped (archived, or the + page left). +- **One save at a time, each starting from the last saved list**, with no fetch first. A + stale-version 409 shows the list its body carries and reapplies the change to it, up to five + times. `setTopicDiscussed()` sets a state rather than toggling, so a reapplied tick can't + undo the counterpart's same tick. Several ticks in a row never conflict with each other. A + change that leaves the list as it is (the counterpart ticked the same topic first) isn't + saved at all, so it bumps no version. +- **Each change has its own promise.** Unlike a "discussed" tick, a change can fail on its own + terms (the topic it edits was deleted meanwhile), so the card reports each one. Nothing is + shown before it is saved, except a tick: the box shows the intended state at once + (`pendingDiscussed`) and goes back if its save fails. The box is not disabled while its save + runs, so it keeps keyboard focus (#149, #151) and can be changed again; only a topic's latest + change clears its pending state. +- **The live-update poll hands a newer list to the sync**, which ignores it while a save is + under way. An open edit form is no reason to hold a refresh back: it keeps its text while + the list around it changes. +- **The card's open action is one value**, `TopicAction`: `idle | adding | editing(id, saving) + | confirmingDelete(id, deleting)`, local to the card. What the card acts on is derived from + it: none once archived, and none for an edit or delete whose topic is no longer in the list, + so a row removed from another tab can't leave the card stuck. The page keys the card by + meeting id. +- **The archived 409 stops the sync and moves the page to archived at once**, exactly as + `DiscussedSync` does, so the page has one rule for both. An in-between version asked + `live-state` first; that left `handleArchive()` reading `archived` before the answer came, + and sent every queued change to be refused too. +- **Archiving waits for the sync, locks the card, and names the topics version it carried + from.** `settled()` is true at once when nothing is pending, whatever happened earlier; if a + change under way at the click fails, the click doesn't archive, like after a failed + "discussed" save, since the tick would otherwise be frozen as not made. The card's controls + are disabled while the archive request runs. The carry-forward is built from the sync's + list, and the request carries that list's `topicsVersion`. `markArchivedIfOpen()` takes it + as one more condition of its `UPDATE`, so no topics save can land between the check and the + archive. If the list has moved on (the list on screen can be a poll interval behind), the + archive is refused (`AnketaTopicsChangedException`, its own exception, so a refusal can't be + mistaken for "already archived") with the same body as a refused topics save and the meeting + stays open; + the page takes that list and archives again, up to three times. A fresh read just before + the request was tried first and left a window one request long. Only checked when a + successor is created; a client that sends no version isn't checked. +- **An add is idempotent.** The topic is made once (`newTopic()`), outside the queued change, + and `addTopic()` skips an item the list already has; a retry of the same text reuses the + item. So a save that landed with its response lost can't add the topic twice. A retry with + different text is a new topic: treating it as a rewording could overwrite the first one. +- **Topic text renders inline Markdown** (`InlineMarkdown.svelte`, #165), like the "What else + to discuss" entries it replaces. + +The first version saved through the page's shared `updateField()` (fetch, apply, save, retry +once), with a hand-rolled queue added in review. Four review rounds each found another race in +it (parallel saves exhausting the one retry, a queued save outliving a page switch, a stuck +edit form), which is what a dedicated per-meeting object was for in #168. The 409-body parser +all three now share is `versionConflictBody()` in `blobSync.ts`. + +Two things changed in `updateField()` along the way and were kept, for its three remaining +callers: + +- It is bound to the meeting id and key it started with. It used to read `id` again after its + first request, so after a same-page switch to another meeting it could save to, and apply its + result on, the wrong one (a gap #168 recorded and left open). +- A 409 without a version in its body is no longer retried as a version conflict. The archived + 409 of `goal-checkpoints` used to be retried with an undefined version and surface as a + validation error instead of "This 1:1 is archived". + +## Accepted limitations + +- **A tab still running the previous release archives without the topics.** Carry-forward is + the archiving browser's job (the server can't re-encrypt), so an old tab sends no + `topicsBlob` and the next meeting starts without the open topics. They stay in the archived + meeting. Only possible for tabs left open across this release. +- **The list is trusted as written.** Either participant holds the meeting key and can write any + blob; a malformed one fails to decrypt or parse like a malformed outcomes blob would. No worse + than what that participant can already do to outcomes and comments. +- **The ciphertext is not padded**, unlike the "discussed" ticks: those are a short list of public + ids, where size alone gives the content away. Topics are free text, like outcomes and comments. + See `docs/encryption.md` for what the server can observe. +- **A tab still running the previous release shows "What else to discuss" on a new meeting**, + since the rule is in the client. Whatever is answered there is stored but not shown by the + current client. The same is true of any form-version change; a reload fixes the tab. +- **`TopicsSync` and `DiscussedSync` are two classes with the same shape** (queue, conflict + reapply, `settled()`, `stop()`). They differ in what a change is (a function that can fail, + with its own promise, against a set-to-state map shown before it's saved), and one generic + queue over both was judged harder to read than two small ones. They share the 409 parser. +- **A change that fails for a harmless reason still stops an archive clicked while it was + under way**, once: `settled()` doesn't tell "the topic was deleted meanwhile" from a failed + save. The card says what happened and the next click archives. `DiscussedSync` does the same. +- **`AnketaTopics.svelte` repeats much of `AnketaOutcomes.svelte`'s row markup and styles.** A + shared list-row component would mean rebuilding the outcomes card, with its comment threads + and page-bound state, inside this change; left for a change of its own. +- **Text typed into an open topic edit is dropped if the meeting is archived meanwhile**, by + either side, with no warning: the same as an outcome's open edit, and unlike an answers edit, + which blocks this tab's Archive button. + +## Verification + +- `frontend/src/anketa/topics.test.ts`: each operation's success / other items untouched / wrong + author / wrong id cases, and the carry-forward with real keys (only undiscussed topics, + readable under the new key and not the old). +- `topicsSync.test.ts`: queued saves run in order with no conflicts among themselves; after a + conflict the counterpart's new topic is kept and a tick isn't undone; a change that no longer + fits is rejected with the current list shown and the queue carries on; repeated conflicts + give up; the archived refusal is reported without a retry; the poll's list is ignored while + saving or when not newer; `settled()` and `stop()`. +- `questions.test.ts`, `templateDefinition.test.ts`, `templateEditor.test.ts`: every built-in + template keeps its discuss blocks before version 3 and drops them from it on; a company + template's `discuss` block resolves at every version; a new template's prefill leaves the + retired questions out, the editor still offers them, and a definition with them is valid. +- Backend: the endpoint's success, stale-version 409, archived 409, malformed payload and + outsider cases; the repository's once-per-version, archived and own-row cases, and that topics + and "discussed" versions don't affect each other; carry-forward on archive and create, dropped + for a one-off; an archive naming a stale topics version is refused with the meeting still open + and no successor, then goes through with the current one; the privacy black-box test round-trips a real encrypted topics blob. +- `frontend/e2e/dual-actor-anketa.spec.ts`, two real browser sessions against the e2e stack: a + topic added by one side appears for the other without a reload, with neither side published; + edit, delete and ticks sync both ways; two ticks at the same moment both land; archive freezes + the list; a topic the archiving tab never heard of makes the archive be refused once and then + succeed with it carried; the successor has only the topics not ticked, readable by both. A second test turns a + meeting into a form-version-2 one in the database and checks it shows "What else to discuss", + takes an answer there, and shows it to the counterpart. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 65853558..b2e4f37f 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -30,6 +30,7 @@ Add a one-line index entry below when you add a file — newest first. ## Index +- [2026-10-04 — A shared "Topics to discuss" list](2026-10-04-shared-topics-list.md) — closes #206. `Anketa::$topicsBlob`/`$topicsVersion`, saved by a conditional `UPDATE` like the "discussed" ticks, visible to both sides with no draft phase, undiscussed topics carried forward like outcomes, and form version 3 dropping the built-in `discuss`/`managerDiscuss` blocks for new meetings only (company templates keep them). - [2026-10-04 — Opt-in "Remember this browser for 30 days"](2026-10-04-remember-this-browser.md) — closes #205. A login with the checkbox gets a session that ends 30 days after the login and keeps the master key in IndexedDB under a non-extractable AES-GCM key, so new tabs and a restarted browser need no password. The threat model is stated plainly: whoever can use the browser or copy its profile can read the 1:1s. A password change or reset now ends every other session (a stamp of `authHash` in the session). The record has the table of who writes and deletes the key, and how the second key source fits the unlock state machine. - [2026-10-03 — A permanent per-pair link for calendar events](2026-10-03-pair-calendar-link.md) — closes #203. `/pair/{userIdA}/{userIdB}`, the same link for both people, opens the pair's open chain meeting (else a page offering to schedule the next one), picked client-side from the caller's own list; the meeting page's "Calendar link" button copies it. A pair has one chain whichever role leads it, so the link ignores roles. - [2026-10-03 — Sessions survive deploys and end after 12 idle hours](2026-10-03-sessions-survive-deploys-idle-timeout.md) — closes #194. Session files move to `var/sessions` (inside the volume every topology mounts, and outside the `var/cache/prod` the deploy step deletes); `AuthSession` enforces a 12-hour idle timeout itself with a `last_active_at` timestamp, since PHP's probabilistic session GC can't; `gc_maxlifetime` is 48 hours, for disk cleanup only. diff --git a/docs/encryption.md b/docs/encryption.md index 59f0e780..7ee9856a 100644 --- a/docs/encryption.md +++ b/docs/encryption.md @@ -79,7 +79,7 @@ Each 1:1 (a single meeting cycle between one manager and one employee; the code 2. It's **sealed** — `crypto_box_seal`, libsodium's anonymous public-key encryption — to each participant's X25519 public key, once per side. "Anonymous" means no sender keypair is needed or used; anyone can seal a message to a public key, but only the holder of the matching private key can open it. This is what lets the creator hand the same key to a counterpart they may never interact with directly, through the server, without the server ever holding the unsealed key. 3. Both sealed copies (`employeeSealedKey`, `managerSealedKey`) go to the server. Each participant unseals *their own* copy locally with their own private key when they load the 1:1. -Everything the two participants share about that 1:1 — both participants' published question answers, the shared comment thread, the outcomes list, goal progress checkpoints, which questions were marked as discussed during the meeting — is encrypted with this one key. Each participant's own [private notes](#private-notes) on it are not: the counterpart holds this key, so the notes have a key of their own. +Everything the two participants share about that 1:1 — both participants' published question answers, the shared comment thread, the topics list, the outcomes list, goal progress checkpoints, which questions were marked as discussed during the meeting — is encrypted with this one key. Each participant's own [private notes](#private-notes) on it are not: the counterpart holds this key, so the notes have a key of their own. ### Envelope format @@ -141,6 +141,7 @@ Assume the worst case: an attacker has read access to the entire database, every - Which meeting day each 1:1's "did it happen?" follow-up email was for (`followUpMeetingDay`, [GitHub issue #202](https://github.com/aleksejs1/encrypted1on1/issues/202)). Like the reminder day above, for a meeting moved after its follow-up it keeps the earlier date, and it shows that the meeting was still open the business day after that date. - That a 1:1 exists, was published, has N comments — metadata, not content. - When the "discussed" checkboxes on a 1:1 were changed. The list is padded to a fixed size (`DISCUSSED_PADDING_BYTES` in `frontend/src/anketa/discussed.ts`) before it's encrypted, since the question IDs differ in length and the built-in ones are public: its size reveals neither which questions are ticked nor how many, unless a very long custom template's list outgrows one padding step, which reveals a rough count. +- That a 1:1's shared topics list was changed, when, and its ciphertext size ([GitHub issue #206](https://github.com/aleksejs1/encrypted1on1/issues/206)). Topics are added during the week, not only at the meeting, so the server sees when someone in the pair touched the list and roughly how much text it holds, though not who or what. It isn't padded, like outcomes and comments: free text has no fixed set of values for its size to give away. - That a user has private notes on a 1:1, their ciphertext size, and when they were saved. Notes autosave about a second after typing stops, so the server sees a **typing-activity timeline and a close estimate of the notes' length over time**: finer-grained than for any other encrypted field. - Which admin invited whom, account creation dates, blocked/admin flags. @@ -148,6 +149,7 @@ Assume the worst case: an attacker has read access to the entire database, every - 1:1 question answers, from either side, published or draft. - Comment text. - Outcome items' text. +- Topics' text, who added each one, and which were ticked off as discussed. - Private notes' text. Inside the app, neither the counterpart nor a company admin can learn whether they exist; someone who can read the database sees what's listed under *Visible*. - Goal progress checkpoint text. - Which questions were marked as discussed. diff --git a/docs/history.md b/docs/history.md index 62085449..cc59b8e6 100644 --- a/docs/history.md +++ b/docs/history.md @@ -174,3 +174,4 @@ Phases 1–5, 6a–6k, and 7a–7f are all done. **Phase 8 (visual design system - **List banner for a meeting left open longer than its period** — closes [GitHub issue #204](https://github.com/aleksejs1/encrypted1on1/issues/204), part of the product-adoption tracking issue [#213](https://github.com/aleksejs1/encrypted1on1/issues/213). Frontend only. `AnketaList.svelte` shows one line per such meeting above the list ("{name}: the 1:1 on {date} isn't closed yet."), with a "Close and schedule the next one" link to the meeting page's archive form (the follow-up email's `#close` fragment, so no new landing code). The rule and the suggested date are two pure functions in `frontend/src/anketa/longOpen.ts`: `isOpenPastPeriod()` (open, not a one-off, more than `periodicityDays` past its date) and `nextCadenceDate()` (the meeting date plus the fewest whole periods that reach today). `Anketa.svelte`'s `load()` uses the cadence date as the archive form's default for such a meeting however the page was opened, not only from the banner; every other meeting keeps today plus one period. The banner text adds the counterpart's name to the issue's wording, since several meetings can be listed, and skips a meeting whose counterpart was deleted. Verified with unit tests for both functions and a new e2e test in `dual-actor-anketa.spec.ts` against the e2e stack. - **Opt-in "Remember this browser for 30 days"** — closes [GitHub issue #205](https://github.com/aleksejs1/encrypted1on1/issues/205), part of the product-adoption tracking issue [#213](https://github.com/aleksejs1/encrypted1on1/issues/213). A checkbox on the login form (off by default, all 6 locales, written by Claude, not native-speaker-reviewed). Backend: `LoginRequest::$rememberMe`, `AuthSession::logIn(..., bool $remember)` stores `remembered_until` (login + `REMEMBER_SECONDS`, 30 days, not extended by activity; the idle timeout doesn't apply to such a session) and asks `migrate()` for a cookie of the same lifetime; `POST /api/login` answers `rememberedSecondsLeft`; the demo account is always refused; `gc_maxlifetime` is 31 days. Frontend: `crypto/rememberedKey.ts` keeps the master key in IndexedDB under a non-extractable AES-GCM key with the session's end date, and `ensureUnlocked()` tries the tab's key, then the remembered one, which it only reads. Two `code-review` rounds reshaped the cross-tab rules: deleting a remembered key that failed to unwrap, and re-creating it from "Unlock this tab", both let one tab's stale view destroy or resurrect what another tab had just done, so the key is now created only by a remembered login, replaced in place elsewhere, and never deleted for being wrong. The e2e restart test also found that PHP re-sends the session cookie without an expiry on every request `closeForReading()` handles, which turned the 30-day cookie back into a browser-session one; `closeForReading()` now removes that header. Three points differ from the issue, all agreed with the maintainer before the code: a password change or reset ends every other session (a `credential_stamp` of `authHash` in the session, no token table), so a password change elsewhere leads to the login screen instead of "Unlock this tab"; the remembered key carries an end date and is deleted once it passes, so it can't stay on disk after its session; and the 30 days are fixed from the login rather than an idle threshold. The issue's claim that a non-extractable key protects against copying the key off the disk was dropped as untrue (the wrapping key is in the same profile); `docs/encryption.md` says what it does and doesn't protect. The `BroadcastChannel` alternative wasn't built. One new dev dependency, `fake-indexeddb` (Apache-2.0), for the unit tests. See `docs/decisions/2026-10-04-remember-this-browser.md`. - **v1.7.0 tagged/released** — a minor release for everything since v1.6.0: the product-adoption batch of [#213](https://github.com/aleksejs1/encrypted1on1/issues/213) (the "anketa" → "1:1" rename #195/#196, the first-use fixes #197–#199, meeting emails #200, neutral "not closed" #201, sessions that survive deploys #194, the follow-up email #202, the per-pair calendar link #203, the long-open banner #204, "Remember this browser" #205), business-day reminders (#167), inline Markdown in list entries and as-typed free text (#165, #178), expired invite recovery (#169), the published-answers edit bar (#166), template portability (#163), the demo-account id fix, and dependency bumps. It carries three database schema migrations for SQLite and MySQL (`anketas.reminderMeetingDay`, `anketas.followUpMeetingDay`, `invite_records.renewalRequestedAt` plus two `activation_tokens` indexes) and two operator-facing changes: `app:send-reminders` must run every day in UTC, and the deploy that brings in `var/sessions` signs everyone out once. Frontend version bumped to 1.7.0 in `package.json`/`package-lock.json` (edited directly, no dependency changes). Went through a PR like any other change; tagged with the signed-annotated-tag convention and released via `gh release create`. +- **Shared "Topics to discuss" list** — closes [GitHub issue #206](https://github.com/aleksejs1/encrypted1on1/issues/206), the last item of the product-adoption tracking issue [#213](https://github.com/aleksejs1/encrypted1on1/issues/213). Drafts are private until published, so topics jotted down during the week were invisible to the other side until then; a shared document doesn't have that problem. The first card on a meeting is now a list either side adds to at any time (`AnketaTopics.svelte`, strings in all 6 locales, written by Claude, not native-speaker-reviewed), shown to the other within one live-update poll, with inline Markdown like the list entries it replaces. Only the author edits or deletes a topic; either side ticks it as discussed; archived, it's frozen. Backend: `Anketa::$topicsBlob`/`$topicsVersion` (SQLite migration generated by `doctrine:migrations:diff` and trimmed to the anketas change, MySQL one hand-written; both run up/down/up, the MySQL one on a throwaway MySQL 8.4), `PUT /api/anketas/{id}/topics` through `AnketaRepository::saveTopicsIfVersion()` (the same conditional `UPDATE` as the "discussed" ticks), `topicsVersion` in live-state, and `topicsBlob` accepted on create and archive and seeded like outcomes (dropped for a one-off). `Anketa::CURRENT_FORM_VERSION` went to 3: `getQuestionsForSide()` leaves `discuss`/`managerDiscuss` out of every built-in template from that version on, in one place, so the three templates that used to ignore the form version are covered too and older meetings keep the blocks with their answers. Company templates keep both ids valid, and the editor still offers them; only the new-template prefill dropped them. The two open questions were settled as: no comments on topics in this version, and topic ticks independent of the per-block "discussed" ticks. Eight `code-review` rounds. Round 1 found the one miss that mattered most: demo meetings are created at the current form version, so the seeded "what else" entries would have vanished and both generation scripts would have timed out on a block that no longer exists. The scripts now add that content as topics (`frontend/scripts/topicsCard.mjs`, shared), `app:reset-demo-data` restores `topicsBlob`, and `demo-seed.json` was regenerated for all 6 locales against the isolated e2e stack (the dev database already had the demo accounts; `generate-demo-fixture.mjs` takes `DEMO_FIXTURE_BASE_URL`/`DEMO_FIXTURE_COMPOSE_FILE` for that), then checked by logging in as two demo accounts in a real browser. Rounds 1–4 each found another race in the save path, first built on the page's shared `updateField()` with a hand-rolled queue added on top: parallel saves exhausting the single retry, a queued save outliving a same-page switch, a stuck edit form after a delete from another tab. That was the pattern CLAUDE.md's working-style section warns about, and round 4 said so; the path was replaced by `anketa/topicsSync.ts`, one object per loaded meeting modelled on `DiscussedSync`. Round 5 pointed out that reading the list fresh just before archiving still left a window; the archive request now names the `topicsVersion` its carry-forward was built from, `markArchivedIfOpen()` takes it as one more condition, and a refusal (`AnketaTopicsChangedException`) leaves the meeting open for the page to retry with the current list. Rounds 6–8 were mostly consistency: the archived refusal handled exactly as `DiscussedSync` does, one shared 409-body parser (`versionConflictBody()`), no save for a change that changes nothing. Hiding the two retired questions from the editor's add menu, which the issue allowed "at most", was implemented and then reverted in round 1, since an admin who removed the block couldn't put it back. Two things changed in `updateField()` for its remaining callers: it's bound to the meeting id and key it started with (a gap #168 recorded), and a 409 without a version is no longer retried as a conflict (the archived 409 of `goal-checkpoints` used to surface as a validation error). Accepted and recorded in the decision doc: a tab left open across the release archives without carrying topics and still shows the retired question on new meetings; an open topic edit is dropped if the meeting is archived meanwhile; `AnketaTopics.svelte` repeats much of `AnketaOutcomes.svelte`, and `TopicsSync` much of `DiscussedSync`. Not done: the doc screenshots in `docs/screenshots/` weren't regenerated (the script was fixed, the images still show the page without the card). Verification: backend 849 tests, both PHPStan configs, CS, PhpMetrics; frontend 932 tests, typecheck, lint, knip, format; the full Playwright suite (65) against the e2e stack, including a dual-actor test (a topic added by one side appears for the other with neither published, edits, deletes and simultaneous ticks sync, an archive whose carry-forward is stale is refused once and then carries the late topic, the successor has only the undiscussed topics) and one that turns a meeting into a form-version-2 one in the database and checks its "What else to discuss" block and answer. See `docs/decisions/2026-10-04-shared-topics-list.md`. diff --git a/docs/methodology.md b/docs/methodology.md index 0b9e3554..8fc6749d 100644 --- a/docs/methodology.md +++ b/docs/methodology.md @@ -21,7 +21,7 @@ Answers stay editable for the entire period between meetings — even after publ ## Why the 1:1 stays open the whole cycle -Several fields are lists you add dated entries to over time, not a single text box: what you learned or discovered (growth), things worth calling out as achievements, and topics you want to make sure get discussed. These are append-format on purpose — the idea someone learned something worth noting is far easier to capture in the moment it happens than to reconstruct from memory the night before a meeting that might be weeks away. An entry's own wording can still be corrected in place afterward (e.g. fixing a typo) without losing its original date or position in the log — only deleting and re-adding it would do that. The same applies to the manager's "achievements worth recognizing" for their report. +Several fields are lists you add dated entries to over time, not a single text box: what you learned or discovered (growth), and things worth calling out as achievements. (Topics you want to make sure get discussed have a list of their own, shared with the other person as soon as you add one — see [user-flow.md](user-flow.md#filling-it-out).) These are append-format on purpose — the idea someone learned something worth noting is far easier to capture in the moment it happens than to reconstruct from memory the night before a meeting that might be weeks away. An entry's own wording can still be corrected in place afterward (e.g. fixing a typo) without losing its original date or position in the log — only deleting and re-adding it would do that. The same applies to the manager's "achievements worth recognizing" for their report. Other fields are deliberately the opposite — a single snapshot, not a log: mood and workload are asked as "how do you feel *right now*, and compared to last time" — a trend line across a 1:1's whole lifetime wouldn't mean much, since what matters is where things stand as the meeting approaches, not a diary of every day in between. @@ -35,7 +35,6 @@ Other fields are deliberately the opposite — a single snapshot, not a log: moo - **Growth. What did you learn, discover, take away?** — an ongoing log of things learned, not a single reflection written under deadline. Growth is usually made of small moments that are easy to forget happened at all by the time a performance review rolls around — this is where they get kept. - **What's harder in my work than it should be** — friction: the things that are quietly costing time or energy. Naming this explicitly, as its own question rather than hoping it surfaces in conversation, is what turns a vague sense that something's off into something a manager can actually act on. - **Achievements** — a running list, not a last-minute reconstruction. Doubles as direct input to the [period report](user-flow.md#reports) when performance-review time comes, so recognition doesn't depend on anyone's memory being good months later. -- **What else to discuss** — an open slot for anything the fixed questions above didn't anticipate. The structure above is deliberately not assumed to cover everything worth talking about. ### Manager side @@ -43,7 +42,10 @@ Other fields are deliberately the opposite — a single snapshot, not a log: moo - **Feedback: what's going well, what could improve** — direct, two-sided feedback, not saved up for a formal review months away. Regular small feedback is what actually changes behavior; feedback that only shows up once or twice a year rarely does. - **How can I help / what gets in the way** — turns the manager's role in the conversation from "evaluate" to "unblock." Asked as its own explicit question so it doesn't get skipped in favor of the more evaluative ones above it. - **Achievements worth recognizing** — the manager's own log of things the employee did well, kept independently of the employee's own achievements list. The same thing can be worth noting from either side, and recognition means more when it wasn't prompted by the employee mentioning it first. -- **What else to discuss** — the manager's version of the employee's open slot above, for the same reason. + +### Shared by both sides + +- **Topics to discuss** — an open list for anything the fixed questions didn't anticipate; the structure above is deliberately not assumed to cover everything worth talking about. Unlike the answers, it has no private draft: a topic is visible to the other person as soon as it's added, so nobody arrives at the meeting not knowing what the other wants to raise. It replaced a "What else to discuss" question each side used to have, which stayed hidden until that side published (1:1s created before the change still show it). ## Outcomes vs. goals — tactical vs. strategic diff --git a/docs/user-flow.md b/docs/user-flow.md index dfbc8c65..1c4d42ed 100644 --- a/docs/user-flow.md +++ b/docs/user-flow.md @@ -29,7 +29,7 @@ A few self-service account controls, reachable from a link in the header once lo - **Change password.** Unlike the forgot-password flow above, this is for someone who still remembers their current password and just wants a new one — it re-wraps the *same* existing keypair rather than generating a fresh one, so nothing becomes unreadable and no counterpart ever needs to re-share anything. That includes your own unpublished drafts, whose key comes from the keypair, not the password. After a forgotten-password *reset*, a draft saved before the reset can't be opened any more. It shows up as an empty draft with a notice instead of breaking the page. Requires entering the current password first, the same way any sensitive account action does. - **Meeting reminder emails** can be turned off per-account — the meeting reminder (the day before, or the Friday before a Monday meeting) and the follow-up after a meeting that wasn't closed, specifically. The emails announcing a *new* 1:1 and a *changed meeting date* are not covered by this toggle and always go out: they are how the other participant learns a cycle started, or that the meeting moved. -- **Export your data** downloads everything currently visible to you — your own answers (including an unpublished draft, if you have one — flagged `myDraftUnreadable` instead if it can no longer be opened, e.g. after a forgotten-password reset), any counterpart's published answers, comments, outcomes, goals, and which questions were marked as discussed, plus your private notes in a separate `privateNotes` list (as last saved, and flagged `unreadable` instead if they can no longer be opened) — as a single JSON file, decrypted entirely in the browser. Nothing new is computed on the server for this; it's the same data the app already lets you see, fetched in one batch and gathered into a file instead of being displayed. +- **Export your data** downloads everything currently visible to you — your own answers (including an unpublished draft, if you have one — flagged `myDraftUnreadable` instead if it can no longer be opened, e.g. after a forgotten-password reset), any counterpart's published answers, the shared topics, comments, outcomes, goals, and which questions were marked as discussed, plus your private notes in a separate `privateNotes` list (as last saved, and flagged `unreadable` instead if they can no longer be opened) — as a single JSON file, decrypted entirely in the browser. Nothing new is computed on the server for this; it's the same data the app already lets you see, fetched in one batch and gathered into a file instead of being displayed. - **Delete your account** is permanent and, unlike the two actions above, does affect other people — but narrowly: your own profile and encryption keypair are removed, and any of your own still-unpublished drafts are lost, but every 1:1 you're part of stays exactly as your counterparts already see it. Nothing is cascaded or torn down on their side. ## Your 1:1 list @@ -64,6 +64,8 @@ Each side answers their own set of questions privately — an employee side (moo Each side can only see the other's answers once *that* side has published theirs too — until then, it just shows "not published yet." +**Topics to discuss.** The first card on a 1:1 is a shared list of topics. Either side can add one at any time, before or after publishing, and the other side sees it within a few seconds: unlike your answers, a topic is shared the moment you add it, so jot things down here during the week and the other person arrives knowing what you want to talk about. Only whoever added a topic can edit or delete it; either side can tick it off as discussed during the meeting. When the 1:1 is archived the list is frozen, and the topics not ticked off move to the pair's next 1:1. 1:1s created before this list existed still have their "What else to discuss" question; newer ones don't, since the list replaces it (a company template that includes that question keeps it, and an admin can still add it to one). + **Marking questions as discussed.** During the meeting, either side can tick the circle beside any published question block, on either side, to mark it as discussed. The block dims so the rest of the agenda stands out, and the counterpart's open tab shows the same within a few seconds. It's only a meeting aid: unticked questions don't block archiving, and nothing reports on them. Once the 1:1 is archived the ticks stay as they were, read-only. ## Comments, outcomes, and goals @@ -78,9 +80,9 @@ These three live alongside the questions and work throughout the 1:1's lifetime, When the meeting date passes: - From the day after, either side sees a "The 1:1 on … isn't closed yet" block with three options: **Close and schedule the next one** (jumps to the archive form described below), **Reschedule** (push the date, same 1:1 — also the answer to "we were on vacation"), or **Didn't happen** (archives this cycle as missed; the next 1:1 is still auto-created the same way as below, unless "don't create the next meeting" is checked). The wording is deliberately neutral: nothing is "overdue". -- Otherwise, either participant can **archive** it once both sides feel it's done. Archiving does two things: it closes this cycle, and — unless "don't create the next meeting" is checked — it immediately creates the *next* 1:1 for the same pair, on the same periodicity, with a fresh encryption key generated and sealed the same way as the very first one. The form's **Next meeting type** sets the new 1:1's meeting type. It starts on a regular check-in, since no other type repeats by default, and can be changed to any type; that type's own next meeting is back to a regular check-in unless someone picks it again. "Didn't happen" uses the same choice. Any unchecked items from the outcomes list carry forward into the new 1:1 automatically, still encrypted, still requiring both participants' keys to read. Open goals carry forward too (their plaintext row is copied so status/history persists across cycles). +- Otherwise, either participant can **archive** it once both sides feel it's done. Archiving does two things: it closes this cycle, and — unless "don't create the next meeting" is checked — it immediately creates the *next* 1:1 for the same pair, on the same periodicity, with a fresh encryption key generated and sealed the same way as the very first one. The form's **Next meeting type** sets the new 1:1's meeting type. It starts on a regular check-in, since no other type repeats by default, and can be changed to any type; that type's own next meeting is back to a regular check-in unless someone picks it again. "Didn't happen" uses the same choice. Any unchecked items from the outcomes list, and any topics not ticked off as discussed, carry forward into the new 1:1 automatically, still encrypted, still requiring both participants' keys to read. Open goals carry forward too (their plaintext row is copied so status/history persists across cycles). - If either participant has been blocked by an admin in the meantime, auto-recreation simply doesn't happen — no error, it just stops. -- A pair can have a second open 1:1 alongside its regular one, e.g. a hand-created ad-hoc one using a different meeting template. Such a 1:1 is a *one-off*. It starts without the carried-forward goals and outcomes, since the already-open one has them. Goals and outcomes added inside it don't carry on to the pair's regular meetings either. Archiving it never auto-creates a next 1:1, so the pair's chain never forks into two. The archive form says so instead of offering the next-meeting controls. See [the decision record](decisions/2026-09-23-one-open-anketa-chain-per-pair.md). +- A pair can have a second open 1:1 alongside its regular one, e.g. a hand-created ad-hoc one using a different meeting template. Such a 1:1 is a *one-off*. It starts without the carried-forward goals, outcomes and topics, since the already-open one has them. Goals, outcomes and topics added inside it don't carry on to the pair's regular meetings either. Archiving it never auto-creates a next 1:1, so the pair's chain never forks into two. The archive form says so instead of offering the next-meeting controls. See [the decision record](decisions/2026-09-23-one-open-anketa-chain-per-pair.md). The day before a scheduled meeting, both participants get one reminder email each; for a side that hasn't published their answers yet, it carries an extra line saying so. A Monday meeting is reminded on the Friday before instead, and the email says "on Monday" rather than "tomorrow", so a Monday meeting scheduled by then isn't reminded on Sunday. A Monday meeting scheduled after Friday's reminders went out still gets its reminder on Sunday. Moving a meeting to another day emails the counterpart the new date right away, and the meeting gets a reminder for its new date too, unless that reminder day has already passed. See [the decision record](decisions/2026-09-29-business-day-reminders.md). diff --git a/frontend/e2e/admin-templates.spec.ts b/frontend/e2e/admin-templates.spec.ts index 02cb63a2..1343963b 100644 --- a/frontend/e2e/admin-templates.spec.ts +++ b/frontend/e2e/admin-templates.spec.ts @@ -59,9 +59,11 @@ test('an admin builds, previews, saves, edits, archives and restores a template' await admin.getByRole('link', { name: 'New template' }).click(); await admin.waitForURL('/admin/templates/new'); - // Pre-filled with the Regular check-in, and not savable without a name. + // Pre-filled with the Regular check-in (six employee questions: "What else + // to discuss" isn't in it since the shared topics list, GitHub issue #206), + // and not savable without a name. const employee = admin.locator('section.side').first(); - await expect(employee.locator('li.block')).toHaveCount(7); + await expect(employee.locator('li.block')).toHaveCount(6); await expect(employee.getByText('Mood')).toBeVisible(); await expect(admin.getByText(/stored unencrypted/)).toBeVisible(); await expect(admin.getByRole('button', { name: 'Save' })).toBeDisabled(); @@ -85,7 +87,7 @@ test('an admin builds, previews, saves, edits, archives and restores a template' .getByRole('combobox', { name: 'Add a standard question…' }) .selectOption({ label: 'Feelings' }); await employee.getByRole('button', { name: 'Add', exact: true }).click(); - await expect(employee.locator('li.block')).toHaveCount(7); + await expect(employee.locator('li.block')).toHaveCount(6); await employee.getByRole('button', { name: 'Remove: Feelings' }).click(); await employee.getByRole('button', { name: 'Add a custom question' }).click(); const custom = employee.locator('li.block').last(); diff --git a/frontend/e2e/dual-actor-anketa.spec.ts b/frontend/e2e/dual-actor-anketa.spec.ts index 46d7a06b..333398fb 100644 --- a/frontend/e2e/dual-actor-anketa.spec.ts +++ b/frontend/e2e/dual-actor-anketa.spec.ts @@ -6,7 +6,11 @@ import { type Page, type Route, } from '@playwright/test'; -import { createActivationLink, uniqueEmail } from './helpers/provision.js'; +import { + createActivationLink, + setFormVersion, + uniqueEmail, +} from './helpers/provision.js'; const PASSWORD = 'correct horse battery staple 123'; @@ -1779,9 +1783,9 @@ test('the read-only view keeps real sub-prompt labels above an answer', async ({ await expect(employeeMySide.getByText('archived')).toBeVisible(); await expect(employeeMySide.locator('textarea')).toHaveCount(0); await expect(employeeMySide.locator('.field')).toHaveCount(0); - // The support template's employee side has six question blocks. - await expect(employeeMySide.locator('.block')).toHaveCount(6); - await expect(employeeMySide.locator('.block-empty')).toHaveCount(6); + // The support template's employee side has five question blocks. + await expect(employeeMySide.locator('.block')).toHaveCount(5); + await expect(employeeMySide.locator('.block-empty')).toHaveCount(5); }); /** @@ -2788,6 +2792,238 @@ test('discussed question checkboxes live-sync across sessions and freeze on arch ).toHaveCount(0, { timeout: 8000 }); }); +/** The shared "Topics to discuss" card (GitHub issue #206), above both sides. */ +function topicsCard(page: Page): Locator { + return page.locator('section.card', { + has: page.getByRole('heading', { name: 'Topics to discuss' }), + }); +} + +/** The topic row showing exactly `text`. */ +function topicRow(page: Page, text: string): Locator { + return topicsCard(page).locator('.topic', { + has: page.getByText(text, { exact: true }), + }); +} + +async function addTopic(page: Page, text: string): Promise { + await topicsCard(page).getByPlaceholder('Add a topic…').fill(text); + await topicsCard(page).getByRole('button', { name: 'Add' }).click(); + await expect(topicRow(page, text)).toBeVisible(); +} + +/** + * GitHub issue #206: the shared topics list. Unlike answers, a topic is + * visible to the other side at once, with neither side published; either + * side ticks it off; and only the topics not ticked off move to the pair's + * next meeting, re-encrypted under that meeting's key. + */ +test('topics to discuss are shared before publishing, live-sync, and carry forward when not discussed', async ({ + browser, +}) => { + // About a dozen waits on the 4s live-update poll: well over half the + // default 60s on a fast machine. + test.setTimeout(150_000); + const employeeEmail = uniqueEmail('employee-topics'); + const managerEmail = uniqueEmail('manager-topics'); + const employee = await activate(browser, createActivationLink(employeeEmail)); + const manager = await activate(browser, createActivationLink(managerEmail)); + + const anketaUrl = await createAnketa(employee, managerEmail, 3); + // The manager keeps this tab open throughout: everything below reaches it + // by the live-update poll alone. + await manager.goto(anketaUrl); + await expect(topicsCard(manager).getByText('No topics yet.')).toBeVisible(); + // The card comes before both sides' answers. + await expect(manager.locator('.anketa-main > *').first()).toContainText( + 'Topics to discuss', + ); + + const budget = `E2E-TOPIC-BUDGET-${Date.now()}`; + const rotation = `E2E-TOPIC-ROTATION-${Date.now()}`; + const hiring = `E2E-TOPIC-HIRING-${Date.now()}`; + + // Nobody has published anything, and the manager sees the topic anyway. + await addTopic(employee, budget); + await expect(topicRow(manager, budget)).toBeVisible({ timeout: 8000 }); + await expect(manager.getByText('Not published yet.')).toBeVisible(); + + // And the other way round. The employee's own topic is still there. + await addTopic(manager, rotation); + await addTopic(manager, hiring); + await expect(topicRow(employee, rotation)).toBeVisible({ timeout: 8000 }); + await expect(topicRow(employee, hiring)).toBeVisible(); + await expect(topicsCard(employee).locator('.topic')).toHaveCount(3); + + // Only the author edits or deletes a topic. + await expect( + topicRow(manager, budget).getByRole('button', { name: 'Edit' }), + ).toHaveCount(0); + await expect( + topicRow(manager, budget).getByRole('button', { name: 'Delete' }), + ).toHaveCount(0); + await expect( + topicRow(employee, budget).getByRole('button', { name: 'Edit' }), + ).toBeVisible(); + + // The author edits theirs in place; the counterpart gets the new text. + const budgetEdited = `${budget}-EDITED`; + await topicRow(employee, budget) + .getByRole('button', { name: 'Edit' }) + .click(); + const editInput = topicsCard(employee).locator('.topic-edit-input'); + await expect(editInput).toBeFocused(); + await editInput.fill(budgetEdited); + await editInput.press('Enter'); + await expect(topicRow(employee, budgetEdited)).toBeVisible(); + await expect(topicRow(manager, budgetEdited)).toBeVisible({ timeout: 8000 }); + await expect(topicRow(manager, budget)).toHaveCount(0); + + // Either side ticks any topic off, their own or not. + await topicRow(manager, budgetEdited).getByRole('checkbox').check(); + await expect(topicRow(manager, budgetEdited)).toHaveClass(/discussed/); + await expect( + topicRow(employee, budgetEdited).getByRole('checkbox'), + ).toBeChecked({ timeout: 8000 }); + await expect(topicRow(employee, budgetEdited)).toHaveClass(/discussed/); + + // Both tick different topics at the same moment: the second save hits the + // version conflict and is reapplied to the first one's list, so neither + // tick is lost. + await addTopic(employee, `${budget}-SECOND`); + await expect(topicRow(manager, `${budget}-SECOND`)).toBeVisible({ + timeout: 8000, + }); + await Promise.all([ + topicRow(employee, `${budget}-SECOND`).getByRole('checkbox').check(), + topicRow(manager, hiring).getByRole('checkbox').check(), + ]); + for (const page of [employee, manager]) { + await expect( + topicRow(page, `${budget}-SECOND`).getByRole('checkbox'), + ).toBeChecked({ timeout: 8000 }); + await expect(topicRow(page, hiring).getByRole('checkbox')).toBeChecked({ + timeout: 8000, + }); + } + // Unticking works too. + await topicRow(employee, hiring).getByRole('checkbox').uncheck(); + await expect(topicRow(manager, hiring).getByRole('checkbox')).not.toBeChecked( + { timeout: 8000 }, + ); + + // The author deletes a topic, after confirming. + await topicRow(manager, hiring) + .getByRole('button', { name: 'Delete' }) + .click(); + await topicRow(manager, hiring) + .getByRole('button', { name: 'Confirm delete' }) + .click(); + await expect(topicRow(manager, hiring)).toHaveCount(0); + await expect(topicRow(employee, hiring)).toHaveCount(0, { timeout: 8000 }); + + // A reload shows the same list: it was saved, not only shown. + await employee.reload(); + await expect(topicsCard(employee).locator('.topic')).toHaveCount(3); + await expect( + topicRow(employee, budgetEdited).getByRole('checkbox'), + ).toBeChecked(); + await expect( + topicRow(employee, rotation).getByRole('checkbox'), + ).not.toBeChecked(); + + // The manager adds one more topic that the employee's tab never hears of + // (its live updates are cut off), and the employee archives. The archive + // is refused once, since the carried-forward topics were built from an + // older list, and goes through with the current one: the late topic isn't + // left behind. + await employee.route('**/live-state', (route) => route.abort()); + const late = `E2E-TOPIC-LATE-${Date.now()}`; + await addTopic(manager, late); + const refusedArchive = employee.waitForResponse( + (response) => + response.url().endsWith('/archive') && response.status() === 409, + ); + await employee.getByRole('button', { name: 'Archive' }).click(); + await refusedArchive; + await expectArchived(employee); + await employee.unroute('**/live-state'); + await expect(topicRow(employee, late)).toBeVisible(); + await expect( + topicsCard(employee).getByPlaceholder('Add a topic…'), + ).toHaveCount(0); + await expect(topicsCard(employee).getByRole('button')).toHaveCount(0); + await expect( + topicRow(employee, rotation).getByRole('checkbox'), + ).toBeDisabled(); + await expect( + topicsCard(manager).getByPlaceholder('Add a topic…'), + ).toHaveCount(0, { timeout: 8000 }); + await expect(topicsCard(manager).locator('.topic')).toHaveCount(4); + + // The next meeting starts with the two topics not ticked off, for both: + // the archiving browser re-encrypted them under the new meeting's key. + const successorUrl = await openSuccessor(employee, anketaUrl); + await expect(topicsCard(employee).locator('.topic')).toHaveCount(2); + await expect(topicRow(employee, late)).toBeVisible(); + await expect( + topicRow(employee, rotation).getByRole('checkbox'), + ).not.toBeChecked(); + await manager.goto(successorUrl); + await expect(topicsCard(manager).locator('.topic')).toHaveCount(2); + // Still the manager's own topic there, so still theirs to edit. + await expect( + topicRow(manager, rotation).getByRole('button', { name: 'Edit' }), + ).toBeVisible(); + // And the carried list takes changes like any other. + await topicRow(employee, rotation).getByRole('checkbox').check(); + await expect(topicRow(manager, rotation).getByRole('checkbox')).toBeChecked({ + timeout: 8000, + }); +}); + +/** + * GitHub issue #206: new meetings no longer have the "What else to discuss" + * blocks, which the topics list replaces. A meeting created before that keeps + * the block and what was answered in it. + */ +test('a meeting from before the topics list keeps its "What else to discuss" block and answers', async ({ + browser, +}) => { + const employeeEmail = uniqueEmail('employee-oldform'); + const managerEmail = uniqueEmail('manager-oldform'); + const employee = await activate(browser, createActivationLink(employeeEmail)); + const manager = await activate(browser, createActivationLink(managerEmail)); + const discussTitle = 'What else to discuss'; + + const anketaUrl = await createAnketa(employee, managerEmail, 3); + const employeeMySide = employee.locator('.side-card').first(); + await expect(questionBlock(employeeMySide, 'Mood')).toBeVisible(); + await expect(questionBlock(employeeMySide, discussTitle)).toHaveCount(0); + + setFormVersion(anketaUrl.split('/').pop()!, 2); + await employee.reload(); + + const discussBlock = questionBlock(employeeMySide, discussTitle); + await expect(discussBlock).toBeVisible(); + const entry = `E2E-OLD-DISCUSS-${Date.now()}`; + await discussBlock.getByPlaceholder('Add an entry…').fill(entry); + await discussBlock.getByRole('button', { name: 'Add' }).click(); + await employeeMySide.getByRole('button', { name: 'Publish' }).click(); + await expect(employeeMySide.getByText('Published')).toBeVisible(); + + // The counterpart reads it, and has their own block of the old form too. + await manager.goto(anketaUrl); + await expect( + questionBlock(manager.locator('.side-card').nth(1), discussTitle), + ).toContainText(entry); + await expect( + questionBlock(manager.locator('.side-card').first(), discussTitle), + ).toBeVisible(); + // The topics list is there for an old meeting as well. + await expect(topicsCard(manager)).toBeVisible(); +}); + /** * GitHub issue #203: the pair's permanent link, copied from a meeting's page * for a calendar event, opens the pair's open meeting for either of them, follows the chain to diff --git a/frontend/e2e/helpers/provision.ts b/frontend/e2e/helpers/provision.ts index 6bb33543..74a48d42 100644 --- a/frontend/e2e/helpers/provision.ts +++ b/frontend/e2e/helpers/provision.ts @@ -130,3 +130,17 @@ export function expireAsInvite(email: string, inviterEmail: string): void { ` WHERE t.email = ${sqlString(email)}`, ); } + +/** + * GitHub issue #206: makes an existing meeting one created at an older form + * version, the way every meeting from before a form change is stored. Done in + * SQL because the server stamps the current version on everything it creates. + */ +export function setFormVersion(anketaId: string, formVersion: number): void { + if (!/^[0-9a-f-]{36}$/.test(anketaId) || !Number.isInteger(formVersion)) { + throw new Error('setFormVersion: unexpected anketa id or form version'); + } + runSql( + `UPDATE anketas SET formVersion = ${formVersion} WHERE id = ${sqlString(anketaId)}`, + ); +} diff --git a/frontend/scripts/generate-demo-fixture.mjs b/frontend/scripts/generate-demo-fixture.mjs index dbee18f5..320d2830 100644 --- a/frontend/scripts/generate-demo-fixture.mjs +++ b/frontend/scripts/generate-demo-fixture.mjs @@ -5,6 +5,12 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { DEMO_LOCALES, CONTENT } from './demo-fixture-content.mjs'; import { fillDateInput } from './fillDateInput.mjs'; +import { + addTopic, + outcomeForm, + topicsCard, + topicsSaved, +} from './topicsCard.mjs'; import { ARGON2ID_REDIRECT_TIMEOUT } from './playwrightTimeouts.mjs'; /** @@ -41,7 +47,14 @@ const REPO_ROOT = path.resolve( ); const FIXTURE_PATH = path.join(REPO_ROOT, 'backend/fixtures/demo-seed.json'); -const BASE_URL = 'http://localhost:5173'; +// The dev stack by default. A dev database that already has the demo +// accounts can't activate them again, so the isolated e2e stack works too +// (`make e2e-up` and `npm run dev:e2e`, a fresh database): +// DEMO_FIXTURE_BASE_URL=http://localhost:5174 \ +// DEMO_FIXTURE_COMPOSE_FILE=docker-compose.e2e.yml node frontend/scripts/generate-demo-fixture.mjs +const BASE_URL = process.env.DEMO_FIXTURE_BASE_URL ?? 'http://localhost:5173'; +const COMPOSE_FILE = + process.env.DEMO_FIXTURE_COMPOSE_FILE ?? 'docker-compose.dev.yml'; const PASSWORD = 'e1o1-demo-2026'; const FEELING_LABELS = { @@ -59,7 +72,7 @@ function createActivationLink(email) { [ 'compose', '-f', - 'docker-compose.dev.yml', + COMPOSE_FILE, 'exec', '-T', 'backend', @@ -136,7 +149,6 @@ async function fillEmployeeSide(scope, c) { for (const entry of c.growth) await addListEntry(scope, 0, entry); await fillTextarea(scope, 3, c.harder); for (const entry of c.achievements) await addListEntry(scope, 1, entry); - for (const entry of c.whatElse) await addListEntry(scope, 2, entry); } async function fillManagerSide(scope, c) { @@ -144,7 +156,25 @@ async function fillManagerSide(scope, c) { await fillTextarea(scope, 1, c.feedback); await fillTextarea(scope, 2, c.howCanIHelp); for (const entry of c.achievements) await addListEntry(scope, 0, entry); - for (const entry of c.whatElse) await addListEntry(scope, 1, entry); +} + +/** + * Each side's `whatElse` content goes into the shared topics list: since + * form version 3 a regular 1:1 has no "What else to discuss" question. + */ +async function addTopics(page, anketaId, topics) { + for (const topic of topics) await addTopic(page, anketaId, topic); +} + +/** + * Ticks the page's topics off as discussed, all but the last `keep`: those + * carry forward into the next cycle, like any topic the pair didn't get to. + */ +async function markTopicsDiscussed(page, anketaId, keep = 0) { + const boxes = topicsCard(page).locator('.topic-checkbox:not(:checked)'); + while ((await boxes.count()) > keep) { + await Promise.all([topicsSaved(page, anketaId), boxes.first().check()]); + } } async function publish(page, anketaId, scope) { @@ -180,14 +210,15 @@ async function addComment(managerPage, anketaId, text) { } async function addOutcome(page, anketaId, text) { - await page.getByPlaceholder(/outcome/i).fill(text); + const form = outcomeForm(page); + await form.getByPlaceholder(/outcome/i).fill(text); await Promise.all([ page.waitForResponse( (res) => res.request().method() === 'PUT' && res.url().endsWith(`/api/anketas/${anketaId}/outcomes`), ), - page.getByRole('button', { name: 'Add', exact: true }).click(), + form.getByRole('button', { name: 'Add', exact: true }).click(), ]); } @@ -305,6 +336,7 @@ async function runLocale(browser, localeCode) { await employee.waitForURL(/\/anketas\/[0-9a-f-]+$/); console.log('Cycle 1 anketa created:', cycle1Id); + await addTopics(employee, cycle1Id, c.cycle1.employee.whatElse); await fillEmployeeSide( employee.locator('.side-card').first(), c.cycle1.employee, @@ -313,6 +345,7 @@ async function runLocale(browser, localeCode) { await manager.goto(`${BASE_URL}/anketas/${cycle1Id}`); await manager.waitForLoadState('networkidle'); + await addTopics(manager, cycle1Id, c.cycle1.manager.whatElse); await fillManagerSide( manager.locator('.side-card').first(), c.cycle1.manager, @@ -337,6 +370,7 @@ async function runLocale(browser, localeCode) { // --- Archive cycle 1 -> auto-creates cycle 2 (carries the in-progress goal + outcome) --- await employee.reload(); await employee.waitForLoadState('networkidle'); + await markTopicsDiscussed(employee, cycle1Id); await archive(employee, cycle1Id); const cycle2Id = await currentAnketaId(employee, [cycle1Id]); console.log('Cycle 2 anketa created:', cycle2Id); @@ -344,6 +378,7 @@ async function runLocale(browser, localeCode) { // --- Cycle 2: fill --- await employee.goto(`${BASE_URL}/anketas/${cycle2Id}`); await employee.waitForLoadState('networkidle'); + await addTopics(employee, cycle2Id, c.cycle2.employee.whatElse); await fillEmployeeSide( employee.locator('.side-card').first(), c.cycle2.employee, @@ -352,6 +387,7 @@ async function runLocale(browser, localeCode) { await manager.goto(`${BASE_URL}/anketas/${cycle2Id}`); await manager.waitForLoadState('networkidle'); + await addTopics(manager, cycle2Id, c.cycle2.manager.whatElse); await fillManagerSide( manager.locator('.side-card').first(), c.cycle2.manager, @@ -373,9 +409,11 @@ async function runLocale(browser, localeCode) { await addCheckpoint(employee, cycle2Id, c.cycle2.checkpoint); console.log('Cycle 2 content filled.'); - // --- Archive cycle 2 -> auto-creates cycle 3 (current, left empty) --- + // --- Archive cycle 2 -> auto-creates cycle 3 (current: no answers yet, + // and the one topic the pair didn't get to, carried forward) --- await employee.reload(); await employee.waitForLoadState('networkidle'); + await markTopicsDiscussed(employee, cycle2Id, 1); await archive(employee, cycle2Id); const cycle3Id = await currentAnketaId(employee, [cycle1Id, cycle2Id]); console.log('Cycle 3 (current) anketa created:', cycle3Id); @@ -404,6 +442,8 @@ async function runLocale(browser, localeCode) { outcomesVersion: fromEmployee.outcomesVersion, goalCheckpointsBlob: fromEmployee.goalCheckpointsBlob, goalCheckpointsVersion: fromEmployee.goalCheckpointsVersion, + topicsBlob: fromEmployee.topicsBlob, + topicsVersion: fromEmployee.topicsVersion, }; }); diff --git a/frontend/scripts/generate-doc-screenshots.mjs b/frontend/scripts/generate-doc-screenshots.mjs index b97f927a..68e6858d 100644 --- a/frontend/scripts/generate-doc-screenshots.mjs +++ b/frontend/scripts/generate-doc-screenshots.mjs @@ -5,6 +5,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { DEMO_LOCALES as ALL_DEMO_LOCALES } from './demo-fixture-content.mjs'; import { fillDateInput } from './fillDateInput.mjs'; +import { addTopic, outcomeForm } from './topicsCard.mjs'; import { ARGON2ID_REDIRECT_TIMEOUT } from './playwrightTimeouts.mjs'; /** @@ -354,9 +355,9 @@ await addListEntry( 0, "First-time mentor for a new hire's on-call rotation — smooth ramp-up, no incidents.", ); -await addListEntry( - mgrSide, - 1, +await addTopic( + manager, + anketaId, 'Ready to talk through what leading a cross-team project would actually look like for Priya next quarter.', ); await publish(manager, anketaId, mgrSide); @@ -427,9 +428,9 @@ await addListEntry( 1, 'Mentored the new hire through their first on-call rotation without a single escalation.', ); -await addListEntry( - empSide, - 2, +await addTopic( + employee, + anketaId, 'Interested in leading a cross-team project next quarter — want to talk about what that path looks like.', ); await publish(employee, anketaId, empSide); @@ -454,7 +455,9 @@ await Promise.all([ res.request().method() === 'PUT' && res.url().endsWith(`/api/anketas/${anketaId}/outcomes`), ), - employee.getByRole('button', { name: 'Add', exact: true }).click(), + outcomeForm(employee) + .getByRole('button', { name: 'Add', exact: true }) + .click(), ]); await manager.reload(); @@ -470,7 +473,9 @@ await Promise.all([ res.request().method() === 'PUT' && res.url().endsWith(`/api/anketas/${anketaId}/outcomes`), ), - manager.getByRole('button', { name: 'Add', exact: true }).click(), + outcomeForm(manager) + .getByRole('button', { name: 'Add', exact: true }) + .click(), ]); // --- Goal + one checkpoint, added by the employee (goal author) --- diff --git a/frontend/scripts/topicsCard.mjs b/frontend/scripts/topicsCard.mjs new file mode 100644 index 00000000..4448eaaf --- /dev/null +++ b/frontend/scripts/topicsCard.mjs @@ -0,0 +1,36 @@ +/** + * Playwright helpers for the meeting page's shared "Topics to discuss" card + * (GitHub issue #206), shared by generate-demo-fixture.mjs and + * generate-doc-screenshots.mjs like fillDateInput.mjs: since form version 3 + * the list holds what the "What else to discuss" question did. + */ +export function topicsCard(page) { + return page.locator('section.card', { + has: page.getByRole('heading', { name: 'Topics to discuss' }), + }); +} + +/** Resolves once a topics save for `anketaId` has succeeded. */ +export function topicsSaved(page, anketaId) { + return page.waitForResponse( + (res) => + res.request().method() === 'PUT' && + res.url().endsWith(`/api/anketas/${anketaId}/topics`) && + res.ok(), + ); +} + +export async function addTopic(page, anketaId, text) { + const card = topicsCard(page); + await card.getByPlaceholder('Add a topic…').fill(text); + await Promise.all([ + topicsSaved(page, anketaId), + card.getByRole('button', { name: 'Add', exact: true }).click(), + ]); + await card.locator('.topic', { hasText: text }).waitFor(); +} + +/** The outcomes card's add form: the topics card has an Add button too. */ +export function outcomeForm(page) { + return page.locator('form', { has: page.getByPlaceholder(/outcome/i) }); +} diff --git a/frontend/src/admin/templateEditor.test.ts b/frontend/src/admin/templateEditor.test.ts index 9478b59a..f72bdd54 100644 --- a/frontend/src/admin/templateEditor.test.ts +++ b/frontend/src/admin/templateEditor.test.ts @@ -1,9 +1,12 @@ import { describe, expect, it } from 'vitest'; import { repoFile } from '../testRepoFile'; import { + CURRENT_ANKETA_FORM_VERSION, EMPLOYEE_BUILTIN_QUESTION_IDS, MANAGER_BUILTIN_QUESTION_IDS, + RETIRED_BUILTIN_QUESTION_IDS, builtinQuestionTitleKey, + getQuestionsForSide, } from '../anketa/questions'; import { validateTemplateDefinition, @@ -42,22 +45,38 @@ describe('ids', () => { }); }); +function offered(ids: readonly T[]): T[] { + return ids.filter((id) => !RETIRED_BUILTIN_QUESTION_IDS.includes(id)); +} + describe('regularPrefill', () => { - it('is the Regular check-in: every built-in of each side, in order, and valid', () => { + it('is the Regular check-in: every offered built-in of each side, in order, and valid', () => { const definition = regularPrefill(); expect(definition.employee).toEqual( - EMPLOYEE_BUILTIN_QUESTION_IDS.map((questionId) => ({ + offered(EMPLOYEE_BUILTIN_QUESTION_IDS).map((questionId) => ({ kind: 'builtin', questionId, })), ); expect(definition.manager).toEqual( - MANAGER_BUILTIN_QUESTION_IDS.map((questionId) => ({ + offered(MANAGER_BUILTIN_QUESTION_IDS).map((questionId) => ({ kind: 'builtin', questionId, })), ); + // The same questions a new Regular 1:1 has. + for (const side of ['employee', 'manager'] as const) { + expect( + definition[side].map((block) => + block.kind === 'builtin' ? block.questionId : null, + ), + ).toEqual( + getQuestionsForSide(side, CURRENT_ANKETA_FORM_VERSION, 'regular').map( + (question) => question.id, + ), + ); + } expect(validateTemplateDefinition(definition)).toEqual([]); }); @@ -65,7 +84,7 @@ describe('regularPrefill', () => { const first = regularPrefill(); first.employee.pop(); expect(regularPrefill().employee).toHaveLength( - EMPLOYEE_BUILTIN_QUESTION_IDS.length, + offered(EMPLOYEE_BUILTIN_QUESTION_IDS).length, ); }); }); @@ -88,8 +107,33 @@ describe('availableBuiltins', () => { ]); }); + // GitHub issue #206: a new template starts without them, since the shared + // topics list does their job, but they're the admin's to add (or to put + // back after removing one), and a definition with them is valid. + it('still offers the "What else to discuss" questions a new template starts without', () => { + const prefill = regularPrefill(); + expect(availableBuiltins('employee', prefill.employee)).toEqual([ + 'discuss', + ]); + expect(availableBuiltins('manager', prefill.manager)).toEqual([ + 'managerDiscuss', + ]); + expect( + validateTemplateDefinition({ + schemaVersion: 1, + employee: [{ kind: 'builtin', questionId: 'discuss' }], + manager: [{ kind: 'builtin', questionId: 'managerDiscuss' }], + }), + ).toEqual([]); + }); + it('offers nothing once every built-in is used', () => { - expect(availableBuiltins('manager', regularPrefill().manager)).toEqual([]); + const everyManagerBuiltin: TemplateBlock[] = + MANAGER_BUILTIN_QUESTION_IDS.map((questionId) => ({ + kind: 'builtin', + questionId, + })); + expect(availableBuiltins('manager', everyManagerBuiltin)).toEqual([]); }); }); diff --git a/frontend/src/admin/templateEditor.ts b/frontend/src/admin/templateEditor.ts index 1eea2e88..9ae4e0a6 100644 --- a/frontend/src/admin/templateEditor.ts +++ b/frontend/src/admin/templateEditor.ts @@ -8,6 +8,7 @@ import { EMPLOYEE_BUILTIN_QUESTION_IDS, MANAGER_BUILTIN_QUESTION_IDS, + RETIRED_BUILTIN_QUESTION_IDS, type EmployeeBuiltinQuestionId, type FieldType, type ManagerBuiltinQuestionId, @@ -60,17 +61,16 @@ export function newOptionValue(): string { /** * A new template starts as the Regular check-in (#133 §6): every built-in - * question of both sides, all removable. Most admins adjust the standard form + * question a new Regular 1:1 has, on both sides, all removable. Most admins adjust the standard form * rather than start from nothing. */ export function regularPrefill(): TemplateDefinition { return { schemaVersion: 1, - employee: EMPLOYEE_BUILTIN_QUESTION_IDS.map((questionId) => ({ - kind: 'builtin', - questionId, - })), - manager: MANAGER_BUILTIN_QUESTION_IDS.map((questionId) => ({ + employee: currentRegular(EMPLOYEE_BUILTIN_QUESTION_IDS).map( + (questionId) => ({ kind: 'builtin', questionId }), + ), + manager: currentRegular(MANAGER_BUILTIN_QUESTION_IDS).map((questionId) => ({ kind: 'builtin', questionId, })), @@ -94,6 +94,15 @@ export function availableBuiltins( return allowed.filter((id) => !used.has(id)); } +/** + * `ids` without the "What else to discuss" questions, which a new Regular + * 1:1 no longer has (GitHub issue #206). Only the prefill leaves them out: + * an admin can still add one, or put back one they removed. + */ +function currentRegular(ids: readonly T[]): T[] { + return ids.filter((id) => !RETIRED_BUILTIN_QUESTION_IDS.includes(id)); +} + /** A new, empty free-text question. */ export function newCustomBlock(): CustomBlock { return { diff --git a/frontend/src/anketa/AnketaTopics.svelte b/frontend/src/anketa/AnketaTopics.svelte new file mode 100644 index 00000000..64c94273 --- /dev/null +++ b/frontend/src/anketa/AnketaTopics.svelte @@ -0,0 +1,488 @@ + + + +{#if !archived || items.length > 0} +
+
+ +

{$_('anketa.topicsHeading')}

+ +
+ {#if !archived} +

+ {$_('anketa.topicsNote')} + {#if !oneOff}{$_('anketa.topicsCarryOver')}{/if} +

+ {/if} + +
+ {#each items as item (item.id)} +
+ + handleSetDiscussed(item.id, event.currentTarget.checked)} + /> + {#if isEditing(item.id)} +
handleEditSubmit(event, item.id)} + > + + + +
+ {:else} + + + {resolveAuthorLabel( + item.authorId, + myUserId, + authorNames, + $_('anketa.you'), + )} + {#if item.authorId === myUserId && !archived} + + {#if isConfirmingDelete(item.id)} + + + {:else} + + + {/if} + + {/if} + {/if} +
+ {:else} +

{$_('anketa.topicsEmpty')}

+ {/each} +
+ + + {#if error && !archived} + + {/if} + + {#if !archived} +
+ + +
+ {/if} +
+{/if} + + diff --git a/frontend/src/anketa/blobSync.ts b/frontend/src/anketa/blobSync.ts index 22a70959..ef13d9b0 100644 --- a/frontend/src/anketa/blobSync.ts +++ b/frontend/src/anketa/blobSync.ts @@ -1,3 +1,4 @@ +import { ApiError } from '../api/client'; import { decryptBlob, encryptBlob } from '../crypto/anketaKey'; export interface BlobSyncPayload { @@ -39,3 +40,50 @@ export async function updateBlobWithRetry( return await attempt(conflict); } } + +/** + * A stale-version 409's body: the server's current blob and version under + * the endpoint's own field names (`commentsBlob`/`commentsVersion`, …). Null + * for anything else, e.g. the "archived" 409, which carries neither and is + * not something to retry against. + */ +export function versionConflictBody( + error: unknown, + blobKey: string, + versionKey: string, +): BlobSyncPayload | null { + if (!(error instanceof ApiError) || error.status !== 409) return null; + const body = error.body as Record | null; + const version = body?.[versionKey]; + if (typeof version !== 'number') return null; + const blob = body?.[blobKey]; + return { blob: typeof blob === 'string' ? blob : null, version }; +} + +/** + * Carry-forward for a shared list (outcomes, topics): the items `keep` + * accepts, encrypted with a new anketa's key. Returns undefined when there's + * nothing to carry, so callers can omit the field entirely rather than send + * an empty-array blob. + */ +export async function carryForwardItems( + items: T[], + newKey: Uint8Array, + keep: (item: T) => boolean, +): Promise { + const kept = items.filter(keep); + return kept.length === 0 ? undefined : encryptBlob(kept, newKey); +} + +/** The same from a prior anketa's blob, decrypted with its own key. */ +export async function carryForwardBlob( + blob: string | null, + oldKey: Uint8Array, + newKey: Uint8Array, + keep: (item: T) => boolean, +): Promise { + if (!blob) return undefined; + + const envelope = await decryptBlob(blob, oldKey); + return carryForwardItems(envelope.data, newKey, keep); +} diff --git a/frontend/src/anketa/discussedSync.ts b/frontend/src/anketa/discussedSync.ts index 143a6bf1..9ec3b20d 100644 --- a/frontend/src/anketa/discussedSync.ts +++ b/frontend/src/anketa/discussedSync.ts @@ -1,4 +1,5 @@ import { ApiError } from '../api/client'; +import { versionConflictBody } from './blobSync'; import { applyDiscussedIntents, type DiscussedBlobData } from './discussed'; /** Queued clicks: question ID → its intended state (see discussed.ts's setDiscussed()). */ @@ -169,7 +170,11 @@ export class DiscussedSync { return true; } catch (error) { if (this.stopped) return false; - const latest = conflictBody(error); + const latest = versionConflictBody( + error, + 'discussedBlob', + 'discussedVersion', + ); if (latest !== null && conflicts < MAX_CONFLICTS) { this.confirmed = await this.options.decrypt(latest.blob); this.confirmedVersion = latest.version; @@ -196,16 +201,3 @@ export class DiscussedSync { this.options.onChange(this.view()); } } - -/** A stale-version 409's body (the server's current list), or null for any other failure. */ -function conflictBody( - error: unknown, -): { blob: string | null; version: number } | null { - if (!(error instanceof ApiError) || error.status !== 409) return null; - const body = error.body as { - discussedBlob?: string | null; - discussedVersion?: unknown; - } | null; - if (typeof body?.discussedVersion !== 'number') return null; - return { blob: body.discussedBlob ?? null, version: body.discussedVersion }; -} diff --git a/frontend/src/anketa/outcomes.ts b/frontend/src/anketa/outcomes.ts index 5626abb9..b3dcb7d4 100644 --- a/frontend/src/anketa/outcomes.ts +++ b/frontend/src/anketa/outcomes.ts @@ -1,4 +1,4 @@ -import { decryptBlob, encryptBlob } from '../crypto/anketaKey'; +import { carryForwardBlob, carryForwardItems } from './blobSync'; /** * "Итоги встречи": tactical agreed action items, jointly visible, no draft @@ -95,16 +95,22 @@ export function deleteOutcome( * undefined when there's nothing to carry, so callers can omit the field entirely * rather than send an empty-array blob. */ -export async function carryForwardOutcomes( +export function carryForwardOutcomes( blob: string | null, oldKey: Uint8Array, newKey: Uint8Array, ): Promise { - if (!blob) return undefined; + return carryForwardBlob(blob, oldKey, newKey, notDone); +} - const envelope = await decryptBlob(blob, oldKey); - const unchecked = envelope.data.filter((item) => !item.done); - if (unchecked.length === 0) return undefined; +/** The same from a list already decrypted: the open anketa's, on archive. */ +export function carryForwardOutcomeItems( + items: OutcomeItem[], + newKey: Uint8Array, +): Promise { + return carryForwardItems(items, newKey, notDone); +} - return encryptBlob(unchecked, newKey); +function notDone(item: OutcomeItem): boolean { + return !item.done; } diff --git a/frontend/src/anketa/questions.test.ts b/frontend/src/anketa/questions.test.ts index 9f096ed6..1eeeaef4 100644 --- a/frontend/src/anketa/questions.test.ts +++ b/frontend/src/anketa/questions.test.ts @@ -21,6 +21,8 @@ function feelingsOptionValues(side: 'employee' | 'manager', version: number) { return field?.options?.map((o) => o.value); } +const DISCUSS_IDS = ['discuss', 'managerDiscuss']; + function questionIds(side: 'employee' | 'manager', templateKey: TemplateKey) { return getQuestionsForSide( side, @@ -123,7 +125,7 @@ describe('getQuestionsForSide', () => { }); // Only 'regular' has a 'feelings'-style field whose options vary by version. - it('does not vary any non-regular template by formVersion', () => { + it('varies a non-regular template by formVersion only in its discuss blocks', () => { for (const templateKey of ANKETA_TEMPLATES.filter((k) => k !== 'regular')) { for (const side of ['employee', 'manager'] as const) { const latest = getQuestionsForSide( @@ -133,7 +135,9 @@ describe('getQuestionsForSide', () => { ); for (let v = 1; v < CURRENT_ANKETA_FORM_VERSION; v++) { expect( - getQuestionsForSide(side, v, templateKey), + getQuestionsForSide(side, v, templateKey).filter( + (q) => !DISCUSS_IDS.includes(q.id), + ), `${templateKey}/${side}/v${v}`, ).toEqual(latest); } @@ -141,6 +145,33 @@ describe('getQuestionsForSide', () => { } }); + // GitHub issue #206: the shared topics list replaces these blocks, but only + // for anketas created from form version 3 on. An older anketa must keep + // them, or its published answers there would no longer be shown. + it('keeps the discuss blocks of every template before form version 3, and drops them from it on', () => { + const withDiscuss: Record = { + regular: ['discuss', 'managerDiscuss'], + onboarding: ['discuss'], + career_growth: ['discuss', 'managerDiscuss'], + support_checkin: ['discuss', 'managerDiscuss'], + }; + for (const templateKey of ANKETA_TEMPLATES) { + const discussIdsAt = (version: number) => + (['employee', 'manager'] as const).flatMap((side) => + getQuestionsForSide(side, version, templateKey) + .map((q) => q.id) + .filter((id) => DISCUSS_IDS.includes(id)), + ); + + expect(discussIdsAt(1), templateKey).toEqual(withDiscuss[templateKey]); + expect(discussIdsAt(2), templateKey).toEqual(withDiscuss[templateKey]); + expect(discussIdsAt(3), templateKey).toEqual([]); + expect(discussIdsAt(CURRENT_ANKETA_FORM_VERSION), templateKey).toEqual( + [], + ); + } + }); + // Field ids are the keys of a side's Answers object, and comment threads // (comments.ts's targetId) are keyed by the bare field id with no side, so a // clash — within one side or across the two — would merge answers or threads. @@ -331,11 +362,10 @@ describe('getQuestionsForSide', () => { 'workStyle', 'freshEyesAudit', 'achievements', - 'discuss', ]); }); - it('keeps mood/achievements/discuss identical to the regular template', () => { + it('keeps mood/achievements identical to the regular template', () => { const regular = getQuestionsForSide( 'employee', CURRENT_ANKETA_FORM_VERSION, @@ -347,7 +377,7 @@ describe('getQuestionsForSide', () => { 'onboarding', ); - for (const id of ['mood', 'achievements', 'discuss']) { + for (const id of ['mood', 'achievements']) { expect(onboarding.find((q) => q.id === id)).toEqual( regular.find((q) => q.id === id), ); @@ -443,22 +473,20 @@ describe('getQuestionsForSide', () => { 'energyRetrospective', 'trajectory', 'developmentPlan', - 'discuss', ]); }); - it('keeps feedback/managerDiscuss and adds sponsorshipOffer on the manager side', () => { + it('keeps feedback and adds sponsorshipOffer on the manager side', () => { expect(questionIds('manager', 'career_growth')).toEqual([ 'feedback', 'sponsorshipOffer', - 'managerDiscuss', ]); }); - it('keeps mood/discuss and feedback/managerDiscuss identical to the regular template', () => { + it('keeps mood and feedback identical to the regular template', () => { for (const [side, ids] of [ - ['employee', ['mood', 'discuss']], - ['manager', ['feedback', 'managerDiscuss']], + ['employee', ['mood']], + ['manager', ['feedback']], ] as const) { const regular = getQuestionsForSide( side, @@ -537,31 +565,27 @@ describe('getQuestionsForSide', () => { const question = (side: 'employee' | 'manager', id: string) => questionFor(side, 'support_checkin', id); - it('keeps mood, then energyLevel/workload/workloadTriage/boundaries/discuss on the employee side', () => { + it('keeps mood, then energyLevel/workload/workloadTriage/boundaries on the employee side', () => { expect(questionIds('employee', 'support_checkin')).toEqual([ 'mood', 'energyLevel', 'workload', 'workloadTriage', 'boundaries', - 'discuss', ]); }); - it('gives the manager side commitments/checkInCadence/managerDiscuss', () => { + it('gives the manager side commitments/checkInCadence', () => { expect(questionIds('manager', 'support_checkin')).toEqual([ 'commitments', 'checkInCadence', - 'managerDiscuss', ]); }); - it('keeps mood/workload/discuss and managerDiscuss identical to the regular template', () => { + it('keeps mood/workload identical to the regular template', () => { for (const [side, id] of [ ['employee', 'mood'], ['employee', 'workload'], - ['employee', 'discuss'], - ['manager', 'managerDiscuss'], ] as const) { expect(question(side, id)).toEqual(questionFor(side, 'regular', id)); } diff --git a/frontend/src/anketa/questions.ts b/frontend/src/anketa/questions.ts index 6e307eef..86ac90b0 100644 --- a/frontend/src/anketa/questions.ts +++ b/frontend/src/anketa/questions.ts @@ -90,7 +90,15 @@ export const SIDES: readonly Side[] = ['employee', 'manager']; * but until that's a discussed product decision this is the simplest way to let the one * global form change over time without breaking anketas created under an older one. */ -export const CURRENT_ANKETA_FORM_VERSION = 2; +export const CURRENT_ANKETA_FORM_VERSION = 3; + +/** + * From this form version on, the built-in templates no longer have their + * "What else to discuss" blocks (`discuss`, `managerDiscuss`): the shared + * topics list (GitHub issue #206, topics.ts) replaces them. Anketas created + * before it keep the blocks and whatever was answered in them. + */ +const TOPICS_LIST_FORM_VERSION = 3; /** * The shape of a `list`-type field's answer — dated entries with stable @@ -795,7 +803,7 @@ export type AnketaTemplateKey = TemplateKey | 'custom'; interface AnketaTemplate { employeeQuestions(formVersion: number): Question[]; - /** The manager side has never varied by version — see `getQuestionsForSide()`. */ + /** Not a function of the version: the one version-dependent change to it is made in `getQuestionsForSide()`. */ managerQuestions: Question[]; /** `CreateAnketa.svelte`'s picker label/description i18n keys. */ labelKey: string; @@ -855,18 +863,42 @@ function templateFor(templateKey: TemplateKey): AnketaTemplate { : TEMPLATES.regular; } -/** The question set for one side of an anketa at a given form version and template. */ +/** + * The question set for one side of an anketa at a given form version and + * template. The "What else to discuss" blocks are dropped here, for every + * built-in template at once, from `TOPICS_LIST_FORM_VERSION` on. A company + * template that lists them keeps them at any version: its questions come + * from `questionsFromDefinition()`, not from here. + */ export function getQuestionsForSide( side: Side, formVersion: number, templateKey: TemplateKey, ): Question[] { const template = templateFor(templateKey); - return side === 'employee' - ? template.employeeQuestions(formVersion) - : template.managerQuestions; + const questions = + side === 'employee' + ? template.employeeQuestions(formVersion) + : template.managerQuestions; + return formVersion >= TOPICS_LIST_FORM_VERSION + ? questions.filter( + (question) => !RETIRED_BUILTIN_QUESTION_IDS.includes(question.id), + ) + : questions; } +/** + * The built-in questions a new company template no longer starts with + * (GitHub issue #206): the shared topics list does their job in every + * meeting. They stay in the allowlists above and in the editor's "add" + * menu, so a template that has one stays valid, keeps showing it, can be + * imported, and an admin can still choose to add one. + */ +export const RETIRED_BUILTIN_QUESTION_IDS: readonly string[] = [ + discussQuestion.id, + managerDiscussQuestion.id, +]; + /** `CreateAnketa.svelte`'s picker label/description i18n keys for `templateKey`. */ export function templatePickerKeys( templateKey: TemplateKey, diff --git a/frontend/src/anketa/templateDefinition.test.ts b/frontend/src/anketa/templateDefinition.test.ts index 53e99aea..bfe56e54 100644 --- a/frontend/src/anketa/templateDefinition.test.ts +++ b/frontend/src/anketa/templateDefinition.test.ts @@ -4,6 +4,7 @@ import { EMPLOYEE_BUILTIN_QUESTION_IDS, FIELD_TYPES, MANAGER_BUILTIN_QUESTION_IDS, + RETIRED_BUILTIN_QUESTION_IDS, CURRENT_ANKETA_FORM_VERSION, getQuestionsForSide, displayText, @@ -398,7 +399,15 @@ describe('questionsFromDefinition', () => { formVersion <= CURRENT_ANKETA_FORM_VERSION; formVersion++ ) { - const regular = getQuestionsForSide(side, formVersion, 'regular'); + // The regular template itself drops its discuss blocks from form + // version 3 on (GitHub issue #206); a company template that lists + // them keeps them, the same questions as before. + const regular = [ + ...getQuestionsForSide(side, formVersion, 'regular'), + ...getQuestionsForSide(side, 2, 'regular').filter((q) => + RETIRED_BUILTIN_QUESTION_IDS.includes(q.id), + ), + ]; const questions = questionsFromDefinition( definition, side, diff --git a/frontend/src/anketa/topics.test.ts b/frontend/src/anketa/topics.test.ts new file mode 100644 index 00000000..1a9661f1 --- /dev/null +++ b/frontend/src/anketa/topics.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, it } from 'vitest'; +import { + decryptBlob, + encryptBlob, + generateAnketaKey, +} from '../crypto/anketaKey'; + +import { + addTopic, + newTopic, + carryForwardTopicItems, + carryForwardTopics, + decryptTopics, + deleteTopic, + editTopic, + setTopicDiscussed, + type TopicItem, +} from './topics'; + +/** A new topic by `authorId`, appended. */ +function add(existing: TopicItem[], authorId: string, text: string) { + return addTopic(existing, newTopic(authorId, text)); +} + +function twoTopics(): TopicItem[] { + return add(add([], 'user-1', 'first'), 'user-2', 'second'); +} + +describe('addTopic', () => { + it('does not add the same topic twice', () => { + const item = newTopic('user-1', 'once'); + const once = addTopic([], item); + + expect(addTopic(once, item)).toBe(once); + }); + + it('appends a new topic that is not discussed yet', () => { + const result = add([], 'user-1', 'budget for the offsite'); + + expect(result).toHaveLength(1); + expect(result[0].discussed).toBe(false); + expect(result[0].authorId).toBe('user-1'); + expect(result[0].text).toBe('budget for the offsite'); + expect(Number.isNaN(Date.parse(result[0].createdAt))).toBe(false); + }); + + it('leaves the existing topics untouched and gives each its own id', () => { + const existing = add([], 'user-1', 'first'); + const result = add(existing, 'user-2', 'second'); + + expect(result[0]).toBe(existing[0]); + expect(result[1].id).not.toBe(result[0].id); + expect(existing).toHaveLength(1); + }); +}); + +describe('setTopicDiscussed', () => { + it('marks the matching topic only', () => { + const items = twoTopics(); + const result = setTopicDiscussed(items, items[0].id, true); + + expect(result[0].discussed).toBe(true); + expect(result[1]).toBe(items[1]); + expect(items[0].discussed).toBe(false); + }); + + it('lets either participant mark a topic, whoever wrote it', () => { + // No author argument at all: unlike edit and delete, this isn't own-only. + const items = twoTopics(); + + expect(setTopicDiscussed(items, items[1].id, true)[1].discussed).toBe(true); + }); + + it('sets rather than toggles, so reapplying it after a conflict changes nothing', () => { + const items = twoTopics(); + const once = setTopicDiscussed(items, items[0].id, true); + const twice = setTopicDiscussed(once, items[0].id, true); + + expect(twice[0].discussed).toBe(true); + expect(setTopicDiscussed(twice, items[0].id, false)[0].discussed).toBe( + false, + ); + }); + + it('throws when the topic id does not match', () => { + expect(() => setTopicDiscussed(twoTopics(), 'no-such-id', true)).toThrow(); + }); + + it('returns the same list when the topic is already in that state', () => { + const items = twoTopics(); + + expect(setTopicDiscussed(items, items[0].id, false)).toBe(items); + }); +}); + +describe('editTopic', () => { + it('replaces the text of the matching topic by the same author', () => { + const items = twoTopics(); + const result = editTopic(items, items[0].id, 'user-1', 'edited'); + + expect(result[0].text).toBe('edited'); + expect(result[0].id).toBe(items[0].id); + expect(result[0].createdAt).toBe(items[0].createdAt); + }); + + it('leaves other topics and the discussed flag untouched', () => { + const fresh = twoTopics(); + const items = setTopicDiscussed(fresh, fresh[0].id, true); + const result = editTopic(items, items[0].id, 'user-1', 'edited'); + + expect(result[0].discussed).toBe(true); + expect(result[1]).toBe(items[1]); + }); + + it('returns the same list when the text is unchanged', () => { + const items = twoTopics(); + + expect(editTopic(items, items[0].id, 'user-1', 'first')).toBe(items); + }); + + it('throws when the author does not match', () => { + const items = twoTopics(); + + expect(() => editTopic(items, items[0].id, 'user-2', 'edited')).toThrow(); + }); + + it('throws when the topic id does not match', () => { + expect(() => + editTopic(twoTopics(), 'no-such-id', 'user-1', 'edited'), + ).toThrow(); + }); +}); + +describe('deleteTopic', () => { + it('removes the matching topic by the same author', () => { + const items = twoTopics(); + const result = deleteTopic(items, items[0].id, 'user-1'); + + expect(result).toEqual([items[1]]); + }); + + it('leaves other topics untouched', () => { + const items = twoTopics(); + + expect(deleteTopic(items, items[1].id, 'user-2')[0]).toBe(items[0]); + }); + + it('throws when the author does not match', () => { + const items = twoTopics(); + + expect(() => deleteTopic(items, items[0].id, 'user-2')).toThrow(); + }); + + it('throws when the topic id does not match', () => { + expect(() => deleteTopic(twoTopics(), 'no-such-id', 'user-1')).toThrow(); + }); +}); + +describe('decryptTopics', () => { + it('reads back an encrypted list, and no blob as an empty one', async () => { + const key = await generateAnketaKey(); + const items = twoTopics(); + + expect(await decryptTopics(await encryptBlob(items, key), key)).toEqual( + items, + ); + expect(await decryptTopics(null, key)).toEqual([]); + }); + + it('fails under another key', async () => { + const blob = await encryptBlob(twoTopics(), await generateAnketaKey()); + + await expect( + decryptTopics(blob, await generateAnketaKey()), + ).rejects.toThrow(); + }); +}); + +describe('carryForwardTopicItems', () => { + it('encrypts only the topics not discussed, and nothing when there are none', async () => { + const newKey = await generateAnketaKey(); + const items = twoTopics(); + const ticked = setTopicDiscussed(items, items[0].id, true); + + const carried = await carryForwardTopicItems(ticked, newKey); + + expect( + (await decryptBlob(carried as string, newKey)).data, + ).toEqual([items[1]]); + expect( + await carryForwardTopicItems( + setTopicDiscussed(ticked, items[1].id, true), + newKey, + ), + ).toBeUndefined(); + }); +}); + +describe('carryForwardTopics', () => { + it('re-encrypts only the topics not discussed under the new key', async () => { + const oldKey = await generateAnketaKey(); + const newKey = await generateAnketaKey(); + const items = twoTopics(); + const blob = await encryptBlob( + setTopicDiscussed(items, items[0].id, true), + oldKey, + ); + + const carried = await carryForwardTopics(blob, oldKey, newKey); + + expect(carried).toBeDefined(); + const { data } = await decryptBlob(carried as string, newKey); + expect(data).toEqual([items[1]]); + await expect(decryptBlob(carried as string, oldKey)).rejects.toThrow(); + }); + + it('carries nothing when every topic was discussed, or there is no list', async () => { + const oldKey = await generateAnketaKey(); + const newKey = await generateAnketaKey(); + const items = add([], 'user-1', 'only'); + const blob = await encryptBlob( + setTopicDiscussed(items, items[0].id, true), + oldKey, + ); + + expect(await carryForwardTopics(blob, oldKey, newKey)).toBeUndefined(); + expect(await carryForwardTopics(null, oldKey, newKey)).toBeUndefined(); + }); +}); diff --git a/frontend/src/anketa/topics.ts b/frontend/src/anketa/topics.ts new file mode 100644 index 00000000..bb9b6238 --- /dev/null +++ b/frontend/src/anketa/topics.ts @@ -0,0 +1,148 @@ +import { decryptBlob } from '../crypto/anketaKey'; +import { carryForwardBlob, carryForwardItems } from './blobSync'; + +/** + * The shared "Topics to discuss" list (GitHub issue #206): what either + * participant wants to talk about, visible to both at once, with no draft + * phase — unlike answers, which stay private until published. Stored like + * outcomes (outcomes.ts): one blob under anketaKey, the whole list rewritten + * on every change, and a version conflict resolved by reapplying the same + * change to the server's latest list (topicsSync.ts). + * + * Only the author edits or deletes a topic; either participant marks it as + * discussed. Both rules are enforced here and in the UI, not by the server, + * which can't read the list. + */ +export interface TopicItem { + id: string; + authorId: string; + text: string; + discussed: boolean; + createdAt: string; +} + +/** + * What the topics card has open besides the list, as one value: at most one + * of these at a time. Ticking "discussed" isn't in here: it needs no open + * form, and several ticks can be waiting to be saved at once. + */ +export type TopicAction = + | { kind: 'idle' } + | { kind: 'adding' } + | { kind: 'editing'; id: string; saving: boolean } + | { kind: 'confirmingDelete'; id: string; deleting: boolean }; + +/** A topicsBlob's list; none yet is an empty list. */ +export async function decryptTopics( + blob: string | null, + key: Uint8Array, +): Promise { + return blob ? (await decryptBlob(blob, key)).data : []; +} + +export function newTopic(authorId: string, text: string): TopicItem { + return { + id: crypto.randomUUID(), + authorId, + text, + discussed: false, + createdAt: new Date().toISOString(), + }; +} + +/** + * Appends `item`, unless the list already has it. The item is made once, by + * newTopic(), and not inside this function: a save whose response was lost + * may have landed, and adding the same item again must not duplicate it. + */ +export function addTopic(existing: TopicItem[], item: TopicItem): TopicItem[] { + return existing.some((other) => other.id === item.id) + ? existing + : [...existing, item]; +} + +/** + * Marks a topic as discussed or not, for either participant. Sets rather + * than toggles: a save that hits a version conflict reapplies this to the + * server's newer list, and if the counterpart ticked the same topic a moment + * earlier, a toggle would untick it again. Throws if the topic is gone, like + * editTopic(). + */ +export function setTopicDiscussed( + existing: TopicItem[], + id: string, + discussed: boolean, +): TopicItem[] { + const current = existing.find((item) => item.id === id); + if (!current) { + throw new Error(`setTopicDiscussed: no item ${id} (deleted elsewhere?)`); + } + // The same list when there's nothing to change, which TopicsSync takes as + // nothing to save. + if (current.discussed === discussed) return existing; + return existing.map((item) => + item.id === id ? { ...item, discussed } : item, + ); +} + +/** Own topics only; throws on a miss, for the same reason as outcomes.ts's editOutcome(). */ +export function editTopic( + existing: TopicItem[], + id: string, + authorId: string, + text: string, +): TopicItem[] { + const current = existing.find( + (item) => item.id === id && item.authorId === authorId, + ); + if (!current) { + throw new Error( + `editTopic: no item ${id} by ${authorId} (deleted elsewhere?)`, + ); + } + // The same list for unchanged text: nothing to save (see setTopicDiscussed()). + if (current.text === text) return existing; + return existing.map((item) => (item.id === id ? { ...item, text } : item)); +} + +/** Own topics only; throws on a miss, like editTopic(). */ +export function deleteTopic( + existing: TopicItem[], + id: string, + authorId: string, +): TopicItem[] { + const filtered = existing.filter( + (item) => !(item.id === id && item.authorId === authorId), + ); + if (filtered.length === existing.length) { + throw new Error( + `deleteTopic: no item ${id} by ${authorId} (deleted elsewhere already?)`, + ); + } + return filtered; +} + +/** + * The topics not yet discussed, re-encrypted for the pair's next meeting — + * the same carry-forward as outcomes.ts's carryForwardOutcomes(), on archive + * and on creating a meeting by hand. + */ +export function carryForwardTopics( + blob: string | null, + oldKey: Uint8Array, + newKey: Uint8Array, +): Promise { + return carryForwardBlob(blob, oldKey, newKey, notDiscussed); +} + +/** The same from a list already decrypted: the open meeting's, on archive. */ +export function carryForwardTopicItems( + items: TopicItem[], + newKey: Uint8Array, +): Promise { + return carryForwardItems(items, newKey, notDiscussed); +} + +function notDiscussed(item: TopicItem): boolean { + return !item.discussed; +} diff --git a/frontend/src/anketa/topicsSync.test.ts b/frontend/src/anketa/topicsSync.test.ts new file mode 100644 index 00000000..56ae73f4 --- /dev/null +++ b/frontend/src/anketa/topicsSync.test.ts @@ -0,0 +1,388 @@ +import { describe, expect, it } from 'vitest'; +import { ApiError } from '../api/client'; +import { + addTopic, + newTopic, + deleteTopic, + editTopic, + setTopicDiscussed, + type TopicItem, +} from './topics'; + +import { TopicsSync } from './topicsSync'; + +/** A new topic by `authorId`, appended. */ +function add(existing: TopicItem[], authorId: string, text: string) { + return addTopic(existing, newTopic(authorId, text)); +} + +/** A stand-in for the server's topics column: a list and its version. */ +class FakeServer { + items: TopicItem[]; + version: number; + archived = false; + /** Every save attempt's expected version, in order. */ + attempts: number[] = []; + /** Held saves, released by the test: lets a save stay in flight. */ + private gate: Promise | null = null; + private openGate: (() => void) | null = null; + failNext: unknown = null; + + constructor(items: TopicItem[] = [], version = 0) { + this.items = items; + this.version = version; + } + + hold(): void { + this.gate = new Promise((resolve) => { + this.openGate = resolve; + }); + } + + release(): void { + this.openGate?.(); + this.gate = null; + } + + /** Another participant's save landing. */ + counterpartSaves(apply: (items: TopicItem[]) => TopicItem[]): void { + this.items = apply(this.items); + this.version++; + } + + save = async (items: TopicItem[], expectedVersion: number) => { + this.attempts.push(expectedVersion); + if (this.gate) await this.gate; + if (this.failNext !== null) { + const failure = this.failNext; + this.failNext = null; + throw failure; + } + if (this.archived) throw new ApiError(409, 'This 1:1 is archived.', {}); + if (expectedVersion !== this.version) { + // The body's blob stands for the ciphertext: the test's decrypt reads + // the list straight from here. + throw new ApiError(409, 'Topics changed.', { + topicsBlob: 'current', + topicsVersion: this.version, + }); + } + this.items = items; + this.version++; + return this.version; + }; +} + +function setup(server = new FakeServer()) { + const shown: TopicItem[][] = []; + let refused = 0; + const sync = new TopicsSync({ + items: server.items, + version: server.version, + save: server.save, + decrypt: () => Promise.resolve(server.items), + onChange: (items) => shown.push(items), + onArchived: () => refused++, + }); + return { sync, server, shown, refusedCount: () => refused }; +} + +const texts = (items: TopicItem[]) => items.map((item) => item.text); + +describe('TopicsSync', () => { + it('saves a change against the loaded version and shows the saved list', async () => { + const { sync, server, shown } = setup(); + + await sync.update((current) => add(current, 'user-1', 'first')); + + expect(texts(server.items)).toEqual(['first']); + expect(server.attempts).toEqual([0]); + expect(sync.version).toBe(1); + expect(texts(sync.items())).toEqual(['first']); + expect(shown.map(texts)).toEqual([['first']]); + expect(sync.busy).toBe(false); + }); + + it('shows nothing before it is saved', async () => { + const { sync, server, shown } = setup(); + server.hold(); + + const saving = sync.update((current) => add(current, 'user-1', 'a')); + await Promise.resolve(); + + expect(sync.busy).toBe(true); + expect(sync.items()).toEqual([]); + expect(shown).toEqual([]); + + server.release(); + await saving; + expect(texts(sync.items())).toEqual(['a']); + }); + + it('saves queued changes one at a time, each on the list the previous one left', async () => { + const { sync, server } = setup(); + + await Promise.all([ + sync.update((current) => add(current, 'user-1', 'a')), + sync.update((current) => add(current, 'user-1', 'b')), + sync.update((current) => add(current, 'user-1', 'c')), + ]); + + expect(texts(server.items)).toEqual(['a', 'b', 'c']); + // No conflicts among this tab's own saves: 0, then 1, then 2. + expect(server.attempts).toEqual([0, 1, 2]); + }); + + it("reapplies a change to the counterpart's newer list after a conflict", async () => { + const { sync, server, shown } = setup( + new FakeServer(add([], 'user-1', 'first'), 4), + ); + server.counterpartSaves((items) => add(items, 'user-2', 'theirs')); + + await sync.update((current) => add(current, 'user-1', 'mine')); + + expect(texts(server.items)).toEqual(['first', 'theirs', 'mine']); + expect(server.attempts).toEqual([4, 5]); + // The counterpart's list is shown as soon as the conflict brings it. + expect(shown.map(texts)).toEqual([ + ['first', 'theirs'], + ['first', 'theirs', 'mine'], + ]); + }); + + it('does not untick a topic the counterpart ticked a moment earlier, and saves nothing more', async () => { + const initial = add([], 'user-1', 'first'); + const { sync, server } = setup(new FakeServer(initial, 1)); + server.counterpartSaves((items) => + setTopicDiscussed(items, initial[0].id, true), + ); + + await sync.update((current) => + setTopicDiscussed(current, initial[0].id, true), + ); + + expect(server.items[0].discussed).toBe(true); + // The refused attempt only; the list it brought already has the tick. + expect(server.attempts).toEqual([1]); + expect(server.version).toBe(2); + expect(sync.version).toBe(2); + }); + + it('sends nothing for a change that changes nothing', async () => { + const item = newTopic('user-1', 'once'); + const { sync, server, shown } = setup(new FakeServer([item], 3)); + + await sync.update((current) => addTopic(current, item)); + await sync.update((current) => setTopicDiscussed(current, item.id, false)); + + expect(server.attempts).toEqual([]); + expect(shown).toEqual([]); + }); + + it('rejects a change that no longer fits, shows the current list, and keeps going', async () => { + const initial = add([], 'user-2', 'theirs'); + const { sync, server } = setup(new FakeServer(initial, 1)); + server.counterpartSaves((items) => + deleteTopic(items, initial[0].id, 'user-2'), + ); + + const tick = sync.update((current) => + setTopicDiscussed(current, initial[0].id, true), + ); + const adding = sync.update((current) => add(current, 'user-1', 'next')); + + await expect(tick).rejects.toThrow(/deleted elsewhere/); + await adding; + expect(texts(sync.items())).toEqual(['next']); + expect(texts(server.items)).toEqual(['next']); + }); + + it("rejects an edit of someone else's topic without saving anything", async () => { + const initial = add([], 'user-2', 'theirs'); + const { sync, server } = setup(new FakeServer(initial, 1)); + + await expect( + sync.update((current) => + editTopic(current, initial[0].id, 'user-1', 'x'), + ), + ).rejects.toThrow(); + expect(server.attempts).toEqual([]); + expect(texts(server.items)).toEqual(['theirs']); + }); + + it('gives up after repeated conflicts, with the latest list shown', async () => { + const server = new FakeServer(); + const save = server.save; + // The counterpart lands a save before every one of this tab's attempts. + server.save = (items, expectedVersion) => { + server.counterpartSaves((current) => + add(current, 'user-2', `theirs ${server.version}`), + ); + return save(items, expectedVersion); + }; + const { sync } = setup(server); + + await expect( + sync.update((current) => add(current, 'user-1', 'mine')), + ).rejects.toBeInstanceOf(ApiError); + + expect(server.attempts).toHaveLength(6); + expect(texts(server.items)).not.toContain('mine'); + expect(sync.items()).toEqual(server.items); + expect(sync.busy).toBe(false); + }); + + it('stops on the archived refusal: one report, queued changes rejected unsent', async () => { + const { sync, server, refusedCount } = setup(); + server.archived = true; + + const first = sync.update((current) => add(current, 'user-1', 'late')); + const second = sync.update((current) => add(current, 'user-1', 'later')); + const settled = sync.settled(); + + await expect(first).rejects.toThrow('This 1:1 is archived.'); + await expect(second).rejects.toThrow(/stopped/); + expect(await settled).toBe(false); + expect(refusedCount()).toBe(1); + // One attempt in all: an archived 409 carries no version to retry + // against, and the second change is never sent. + expect(server.attempts).toEqual([0]); + await expect( + sync.update((current) => add(current, 'user-1', 'after')), + ).rejects.toThrow(/stopped/); + }); + + it('rejects a failed request without reporting a refusal, and saves the next change', async () => { + const { sync, server, refusedCount } = setup(); + server.failNext = new TypeError('Failed to fetch'); + + await expect( + sync.update((current) => add(current, 'user-1', 'lost')), + ).rejects.toThrow('Failed to fetch'); + await sync.update((current) => add(current, 'user-1', 'kept')); + + expect(refusedCount()).toBe(0); + expect(texts(server.items)).toEqual(['kept']); + }); + + describe('applyRemote', () => { + it('takes a newer list from the poll', () => { + const { sync, shown } = setup(); + const remote = add([], 'user-2', 'theirs'); + + sync.applyRemote(remote, 3); + + expect(sync.items()).toBe(remote); + expect(sync.version).toBe(3); + expect(shown).toEqual([remote]); + }); + + it('ignores a list that is not newer', async () => { + const { sync } = setup(); + await sync.update((current) => add(current, 'user-1', 'mine')); + + sync.applyRemote([], 1); + sync.applyRemote([], 0); + + expect(texts(sync.items())).toEqual(['mine']); + }); + + it('ignores the poll while a save is under way', async () => { + const { sync, server } = setup(); + server.hold(); + const saving = sync.update((current) => add(current, 'user-1', 'mine')); + await Promise.resolve(); + + sync.applyRemote(add([], 'user-2', 'stale poll'), 7); + + server.release(); + await saving; + expect(texts(sync.items())).toEqual(['mine']); + expect(sync.version).toBe(1); + }); + + it('still takes the last list once stopped', () => { + const { sync } = setup(); + sync.stop(); + + sync.applyRemote(add([], 'user-2', 'just before archive'), 2); + + expect(texts(sync.items())).toEqual(['just before archive']); + }); + }); + + describe('settled', () => { + it('is true at once when idle, even after an earlier failure', async () => { + const { sync, server } = setup(); + server.failNext = new TypeError('Failed to fetch'); + await expect( + sync.update((current) => add(current, 'user-1', 'lost')), + ).rejects.toThrow(); + + expect(await sync.settled()).toBe(true); + }); + + it('waits for the queue and reports that everything was saved', async () => { + const { sync, server } = setup(); + server.hold(); + const saving = sync.update((current) => add(current, 'user-1', 'a')); + let settled: boolean | null = null; + void sync.settled().then((saved) => (settled = saved)); + await Promise.resolve(); + expect(settled).toBeNull(); + + server.release(); + await saving; + await Promise.resolve(); + + expect(settled).toBe(true); + expect(texts(server.items)).toEqual(['a']); + }); + + it('reports false if a change under way failed', async () => { + const { sync, server } = setup(); + server.hold(); + server.failNext = new TypeError('Failed to fetch'); + const failing = sync.update((current) => add(current, 'user-1', 'a')); + const settled = sync.settled(); + + server.release(); + + await expect(failing).rejects.toThrow(); + expect(await settled).toBe(false); + }); + }); + + describe('stop', () => { + it('takes no new changes', async () => { + const { sync, server } = setup(); + sync.stop(); + + await expect( + sync.update((current) => add(current, 'user-1', 'late')), + ).rejects.toThrow(/stopped/); + expect(server.attempts).toEqual([]); + }); + + it('rejects queued changes but lets the save in flight finish and show', async () => { + const { sync, server } = setup(); + server.hold(); + const inFlight = sync.update((current) => + add(current, 'user-1', 'in flight'), + ); + const queued = sync.update((current) => add(current, 'user-1', 'queued')); + const settled = sync.settled(); + await Promise.resolve(); + + sync.stop(); + server.release(); + + await expect(queued).rejects.toThrow(/stopped/); + await inFlight; + expect(texts(server.items)).toEqual(['in flight']); + expect(texts(sync.items())).toEqual(['in flight']); + // Something asked for wasn't saved. + expect(await settled).toBe(false); + }); + }); +}); diff --git a/frontend/src/anketa/topicsSync.ts b/frontend/src/anketa/topicsSync.ts new file mode 100644 index 00000000..fb901d72 --- /dev/null +++ b/frontend/src/anketa/topicsSync.ts @@ -0,0 +1,194 @@ +import { ApiError } from '../api/client'; +import { versionConflictBody } from './blobSync'; +import type { TopicItem } from './topics'; + +type Change = (current: TopicItem[]) => TopicItem[]; + +interface QueuedChange { + apply: Change; + resolve: () => void; + reject: (reason: unknown) => void; +} + +export interface TopicsSyncOptions { + /** The list and version the page loaded. */ + items: TopicItem[]; + version: number; + /** + * Encrypts and PUTs the whole list against expectedVersion, resolving with + * the new version. Bound to one anketa and its key when the page creates + * the sync, so a save can never reach another anketa. + */ + save: (items: TopicItem[], expectedVersion: number) => Promise; + /** Decrypts a stale-version 409 body's topicsBlob. */ + decrypt: (blob: string | null) => Promise; + /** Called with the list whenever it changes. */ + onChange: (items: TopicItem[]) => void; + /** A save found the anketa archived (the counterpart archived since the last poll). */ + onArchived: () => void; +} + +/** + * Stale-version conflicts in a row before a change is given up on. Each one + * means another save landed in between, so this is only reached if the + * counterpart keeps saving at the same moment. + */ +const MAX_CONFLICTS = 5; + +/** + * The shared topics list of one open anketa (GitHub issue #206): the saved + * list, and the changes to it, saved one at a time in the order they were + * made. Like DiscussedSync, each save starts from the last saved list and + * version rather than refetching, and a stale-version 409 reapplies the + * change to the list its body carries. Unlike it, a change here can fail on + * its own terms (the topic it edits was deleted meanwhile), so every change + * has its own promise, and nothing shows before it is saved. The page + * creates a new instance per loaded anketa. + * + * States: idle (nothing queued or in flight), saving (`running`, maybe with + * more changes queued), and stopped (archived, found out here or by the + * page, or the page left this anketa): queued changes are rejected and no + * new ones are taken, though a save already in flight still finishes and is + * applied. + */ +export class TopicsSync { + private confirmed: TopicItem[]; + private confirmedVersion: number; + private queue: QueuedChange[] = []; + private running = false; + private stopped = false; + /** Resolvers of settled() calls waiting for the queue to drain. */ + private waiters: ((saved: boolean) => void)[] = []; + + constructor(private readonly options: TopicsSyncOptions) { + this.confirmed = options.items; + this.confirmedVersion = options.version; + } + + /** The version of the list last saved or applied, for the live-update poll to compare. */ + get version(): number { + return this.confirmedVersion; + } + + /** While true the poll must not apply a remote list: this tab's own save is under way. */ + get busy(): boolean { + return this.running || this.queue.length > 0; + } + + items(): TopicItem[] { + return this.confirmed; + } + + /** + * Queues a change. Resolves once it's saved; rejects with the save's + * error, with what `apply` threw (the change no longer fits the list), or + * because the sync was stopped first. + */ + update(apply: Change): Promise { + if (this.stopped) return Promise.reject(stoppedError()); + return new Promise((resolve, reject) => { + this.queue.push({ apply, resolve, reject }); + void this.run(); + }); + } + + /** + * A newer list from the live-update poll. Ignored while busy (the save's + * own response or conflict brings the newer list anyway) or if not newer. + * Still taken once stopped: the last change before an archive may arrive + * after it. + */ + applyRemote(items: TopicItem[], version: number): void { + if (this.busy || version <= this.confirmedVersion) return; + this.setConfirmed(items, version); + } + + /** + * Resolves once nothing is queued or in flight: true if every change + * since the queue last drained was saved, false if any failed. True at + * once when idle, whatever happened earlier. Archiving waits for it, so + * the last ticks are saved before the anketa is frozen. + */ + settled(): Promise { + if (!this.busy) return Promise.resolve(true); + return new Promise((resolve) => this.waiters.push(resolve)); + } + + /** The anketa was archived, or the page left it: reject what's queued, take nothing more. */ + stop(): void { + this.stopped = true; + for (const change of this.queue.splice(0)) change.reject(stoppedError()); + } + + private async run(): Promise { + if (this.running) return; + this.running = true; + let allSaved = true; + for ( + let change = this.queue.shift(); + change !== undefined; + change = this.queue.shift() + ) { + try { + await this.send(change.apply); + change.resolve(); + } catch (error) { + allSaved = false; + change.reject(error); + } + } + this.running = false; + // stop() may have emptied the queue under a save in flight. + if (this.stopped) allSaved = false; + for (const resolve of this.waiters.splice(0)) resolve(allSaved); + } + + /** Saves one change, or throws why it couldn't be. */ + private async send(apply: Change): Promise { + for (let conflicts = 0; ; conflicts++) { + const items = apply(this.confirmed); + // Nothing to save: the list already is what the change asks for (the + // counterpart ticked the same topic, or an add that had landed is + // retried). No save, so no version bump for the counterpart to fetch. + if (items === this.confirmed) return; + try { + const version = await this.options.save(items, this.confirmedVersion); + this.setConfirmed(items, version); + return; + } catch (error) { + const latest = versionConflictBody( + error, + 'topicsBlob', + 'topicsVersion', + ); + if (latest === null) { + if (error instanceof ApiError && error.status === 409) { + // The only other 409 PUT /topics returns: the anketa is archived. + // Handled like DiscussedSync does, so the page has one rule for + // both: stop (what's queued would only be refused too) and tell + // the page, which moves to archived at once. + this.stop(); + this.options.onArchived(); + } + throw error; + } + // Shown even if this change is then given up on. + this.setConfirmed( + await this.options.decrypt(latest.blob), + latest.version, + ); + if (conflicts >= MAX_CONFLICTS || this.stopped) throw error; + } + } + } + + private setConfirmed(items: TopicItem[], version: number): void { + this.confirmed = items; + this.confirmedVersion = version; + this.options.onChange(items); + } +} + +function stoppedError(): Error { + return new Error('TopicsSync: stopped (archived, or the page left)'); +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index af4abd9f..b0356568 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -90,6 +90,9 @@ export interface AnketaDetail { goalCheckpointsVersion: number; discussedBlob: string | null; discussedVersion: number; + /** The shared "Topics to discuss" list (GitHub issue #206), under the anketa key. */ + topicsBlob: string | null; + topicsVersion: number; counterpartPublicKey: string; periodicityDays: number | null; missed: boolean; @@ -164,6 +167,7 @@ export interface AnketaLiveState { outcomesVersion: number; goalCheckpointsVersion: number; discussedVersion: number; + topicsVersion: number; } /** One row of GET /api/templates: an active company template (GitHub issue #144). */ diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 03b9b6cc..75f30a9c 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -285,6 +285,12 @@ "outcomesNote": "Gemeinsame Liste — nur der Autor eines Eintrags kann ihn bearbeiten oder abhaken; die andere Seite kann kommentieren.", "outcomesEmpty": "Noch keine Ergebnisse erfasst.", "outcomesPlaceholder": "Ergebnis hinzufügen…", + "topicsHeading": "Gesprächsthemen", + "topicsNote": "Gemeinsame Liste: Die andere Seite sieht ein Thema, sobald Sie es hinzufügen, ohne dass jemand seine Antworten veröffentlichen muss.", + "topicsCarryOver": "Nicht abgehakte Themen werden ins nächste 1:1 übernommen.", + "topicsEmpty": "Noch keine Themen.", + "topicsPlaceholder": "Thema hinzufügen…", + "topicsEditLabel": "Text des Themas", "add": "Hinzufügen", "adding": "Wird hinzugefügt…", "goalsHeading": "Ziele", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 8b538434..506cde62 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -285,6 +285,12 @@ "outcomesNote": "Shared list — only the author of an item can edit or check it off; the other side can comment.", "outcomesEmpty": "No outcomes recorded yet.", "outcomesPlaceholder": "Add an outcome…", + "topicsHeading": "Topics to discuss", + "topicsNote": "Shared list: the other side sees a topic as soon as you add it, without waiting for either of you to publish.", + "topicsCarryOver": "Topics not ticked off move to the next 1:1.", + "topicsEmpty": "No topics yet.", + "topicsPlaceholder": "Add a topic…", + "topicsEditLabel": "Topic text", "add": "Add", "adding": "Adding…", "goalsHeading": "Goals", diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index 5a44c97d..bf9eaee3 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -285,6 +285,12 @@ "outcomesNote": "Lista compartida — solo el autor de un elemento puede editarlo o marcarlo; la otra parte puede comentarlo.", "outcomesEmpty": "Todavía no hay elementos registrados.", "outcomesPlaceholder": "Añadir un elemento…", + "topicsHeading": "Temas a tratar", + "topicsNote": "Lista compartida: la otra parte ve un tema en cuanto lo añades, sin esperar a que nadie publique sus respuestas.", + "topicsCarryOver": "Los temas sin marcar pasan a la siguiente reunión 1:1.", + "topicsEmpty": "Todavía no hay temas.", + "topicsPlaceholder": "Añadir un tema…", + "topicsEditLabel": "Texto del tema", "add": "Añadir", "adding": "Añadiendo…", "goalsHeading": "Objetivos", diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index fe428950..6cffd53a 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -285,6 +285,12 @@ "outcomesNote": "Liste partagée — seul l'auteur d'un élément peut le modifier ou le cocher ; l'autre côté peut commenter.", "outcomesEmpty": "Aucun résultat enregistré pour l'instant.", "outcomesPlaceholder": "Ajouter un résultat…", + "topicsHeading": "Sujets à aborder", + "topicsNote": "Liste partagée : l'autre côté voit un sujet dès que vous l'ajoutez, sans attendre la publication des réponses.", + "topicsCarryOver": "Les sujets non cochés sont reportés au prochain 1:1.", + "topicsEmpty": "Aucun sujet pour l'instant.", + "topicsPlaceholder": "Ajouter un sujet…", + "topicsEditLabel": "Texte du sujet", "add": "Ajouter", "adding": "Ajout en cours…", "goalsHeading": "Objectifs", diff --git a/frontend/src/i18n/locales/lv.json b/frontend/src/i18n/locales/lv.json index 24f2d8bb..df1f26ca 100644 --- a/frontend/src/i18n/locales/lv.json +++ b/frontend/src/i18n/locales/lv.json @@ -285,6 +285,12 @@ "outcomesNote": "Kopīgs saraksts — rediģēt vai atzīmēt punktu var tikai tā autors, otra puse var tikai komentēt.", "outcomesEmpty": "Vēl nav neviena ieraksta.", "outcomesPlaceholder": "Pievienot punktu…", + "topicsHeading": "Apspriežamās tēmas", + "topicsNote": "Kopīgs saraksts: otra puse redz tēmu, tiklīdz to pievienojat, negaidot atbilžu publicēšanu.", + "topicsCarryOver": "Neatzīmētās tēmas tiks pārnestas uz nākamo 1:1 tikšanos.", + "topicsEmpty": "Vēl nav nevienas tēmas.", + "topicsPlaceholder": "Pievienot tēmu…", + "topicsEditLabel": "Tēmas teksts", "add": "Pievienot", "adding": "Pievieno…", "goalsHeading": "Mērķi", diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json index d5d1d4c9..0dda3cf2 100644 --- a/frontend/src/i18n/locales/ru.json +++ b/frontend/src/i18n/locales/ru.json @@ -285,6 +285,12 @@ "outcomesNote": "Общий список — редактировать или отмечать пункт может только его автор, другая сторона может только комментировать.", "outcomesEmpty": "Пока нет записей.", "outcomesPlaceholder": "Добавить пункт…", + "topicsHeading": "Темы для обсуждения", + "topicsNote": "Общий список: другая сторона видит тему сразу после добавления, не дожидаясь публикации ответов.", + "topicsCarryOver": "Неотмеченные темы перейдут в следующую встречу 1:1.", + "topicsEmpty": "Пока нет тем.", + "topicsPlaceholder": "Добавить тему…", + "topicsEditLabel": "Текст темы", "add": "Добавить", "adding": "Добавление…", "goalsHeading": "Цели", diff --git a/frontend/src/pages/AccountSettings.svelte b/frontend/src/pages/AccountSettings.svelte index 66289f79..0de51f88 100644 --- a/frontend/src/pages/AccountSettings.svelte +++ b/frontend/src/pages/AccountSettings.svelte @@ -46,6 +46,7 @@ } from '../anketa/notesExport'; import type { Comment } from '../anketa/comments'; import type { OutcomeItem } from '../anketa/outcomes'; + import { decryptTopics } from '../anketa/topics'; import type { Goal, GoalCheckpoint } from '../anketa/goals'; import { decryptDiscussed } from '../anketa/discussed'; @@ -256,6 +257,7 @@ goals: Goal[]; goalCheckpointsBlob: string | null; discussedBlob: string | null; + topicsBlob: string | null; templateKey: AnketaTemplateKey; formVersion: number; customTemplateVersionId: string | null; @@ -350,6 +352,7 @@ ) ).data : []; + const topics = await decryptTopics(detail.topicsBlob, anketaKey); const discussed = await decryptDiscussed( detail.discussedBlob, anketaKey, @@ -368,6 +371,7 @@ myAnswers, myDraftUnreadable, counterpartAnswers, + topics, comments, outcomes, goals: detail.goals, diff --git a/frontend/src/pages/Anketa.svelte b/frontend/src/pages/Anketa.svelte index 8cfd7abd..8f60abbd 100644 --- a/frontend/src/pages/Anketa.svelte +++ b/frontend/src/pages/Anketa.svelte @@ -15,6 +15,7 @@ startCreateAnother, } from '../anketa/createDefaults'; import AnketaOutcomes from '../anketa/AnketaOutcomes.svelte'; + import AnketaTopics from '../anketa/AnketaTopics.svelte'; import AnketaGoals from '../anketa/AnketaGoals.svelte'; import AnketaArchiveSection from '../anketa/AnketaArchiveSection.svelte'; import PrivateNotes, { @@ -32,7 +33,16 @@ saveDraftBackup, } from '../anketa/draftBackup'; import { decryptDraft, hasAnyAnswer } from '../anketa/drafts'; - import { carryForwardOutcomes, type OutcomeItem } from '../anketa/outcomes'; + import { + carryForwardOutcomeItems, + type OutcomeItem, + } from '../anketa/outcomes'; + import { + carryForwardTopicItems, + decryptTopics, + type TopicItem, + } from '../anketa/topics'; + import { TopicsSync } from '../anketa/topicsSync'; import { pruneStaleBusyEntries } from '../anketa/commentThreadsBusy'; import { decryptDiscussed, @@ -60,7 +70,7 @@ fetchCompanyTemplates, fetchTemplateVersion, } from '../api/templates'; - import { updateBlobWithRetry } from '../anketa/blobSync'; + import { updateBlobWithRetry, versionConflictBody } from '../anketa/blobSync'; import { beginAction, fallbackFocusOptions, @@ -284,6 +294,16 @@ editingOutcomeId !== null || confirmingDeleteOutcomeId !== null, ); + /** + * The shared "Topics to discuss" list (GitHub issue #206): one TopicsSync + * per loaded anketa, like discussedSync below, created once its key and + * list are known. Null until then, which hides the card. `allTopics` + * mirrors its list. + */ + // $state.raw: a class instance, replaced whole, never mutated through the proxy. + let topicsSync = $state.raw(null); + let allTopics = $state([]); + /** * Live updates (poll a cheap endpoint, refresh whichever sections changed * without a manual reload) — see private/live-updates-proposal.md (not @@ -393,7 +413,10 @@ // even if applied. const readAbort = abortOnDestroy(); // No more discussed saves once the page is gone (one in flight finishes). - onDestroy(() => discussedSync?.stop()); + onDestroy(() => { + discussedSync?.stop(); + topicsSync?.stop(); + }); // The "1:1 created." offer is for the visit right after creating it only: // dropped when this page moves to another 1:1 (the instance is reused // across ids) or is left. @@ -434,6 +457,9 @@ discussedSync = null; discussedQuestions = []; discussedError = null; + topicsSync?.stop(); + topicsSync = null; + allTopics = []; }); // Set when the stored draft should be re-saved as soon as the page is // loaded, without waiting for an edit — see below. @@ -527,6 +553,34 @@ allOutcomes = envelope.data; } + // Bound to this anketa's id and key, like the discussed sync below. + const topicsAnketaId = anketa.id; + const topics: TopicsSync = new TopicsSync({ + items: await decryptTopics(anketa.topicsBlob, key), + version: anketa.topicsVersion, + save: async (items, expectedVersion) => { + const blob = await encryptBlob(items, key); + const result = await apiPut<{ topicsVersion: number }>( + `/api/anketas/${topicsAnketaId}/topics`, + { blob, expectedVersion }, + ); + return result.topicsVersion; + }, + decrypt: (blob) => decryptTopics(blob, key), + onChange: (items) => { + if (topicsSync === topics) allTopics = items; + }, + onArchived: () => { + if (topicsSync === topics) enterArchivedState(); + }, + }); + if (id === loadId) { + topicsSync?.stop(); + topicsSync = topics; + allTopics = topics.items(); + if (anketa.archivedAt !== null) topics.stop(); + } + goals = anketa.goals; if (anketa.goalCheckpointsBlob) { const envelope = await decryptBlob( @@ -953,6 +1007,8 @@ live[counterpartKeys.blobVersion] !== appliedCounterpartBlobVersion; const commentsChanged = live.commentsVersion !== appliedCommentsVersion; const outcomesChanged = live.outcomesVersion !== appliedOutcomesVersion; + const topicsChanged = + topicsSync !== null && live.topicsVersion > topicsSync.version; // `>`: a live-state read that left before this tab's own save landed can // be older than the version that save already confirmed. const discussedChanged = @@ -969,6 +1025,7 @@ !counterpartBlobChanged && !commentsChanged && !outcomesChanged && + !topicsChanged && !checkpointsChanged && !discussedChanged ) { @@ -1013,6 +1070,11 @@ !anyCommentThreadBusy; const willApplyCheckpoints = checkpointsChanged && !anyCheckpointAdding && !anyCommentThreadBusy; + // The same for the topics: an open edit form there is no reason to wait, + // since it keeps its text while the list around it changes. + const pollTopicsSync = topicsSync; + const willApplyTopics = + topicsChanged && pollTopicsSync !== null && !pollTopicsSync.busy; const willApplyMyBlob = myBlobChanged && !wasEditingMyAnswers; // DiscussedSync.applyRemote() re-checks busy and the version itself, after // the awaits below, so the snapshot here only saves a pointless fetch. @@ -1026,6 +1088,7 @@ willApplyMyBlob || willApplyComments || willApplyOutcomes || + willApplyTopics || willApplyCheckpoints || willApplyDiscussed; if (!needsFullDetail) return; @@ -1098,6 +1161,11 @@ } } + if (willApplyTopics && pollTopicsSync === topicsSync) { + const decrypted = await decryptTopics(fresh.topicsBlob, anketaKey); + pollTopicsSync?.applyRemote(decrypted, fresh.topicsVersion); + } + if (willApplyCheckpoints) { const previousIds = allCheckpoints.map((c) => c.id); const decrypted = fresh.goalCheckpointsBlob @@ -1394,6 +1462,7 @@ archived = true; exitAnswersEditSession(); discussedSync?.stop(); + topicsSync?.stop(); } /** @@ -1511,18 +1580,23 @@ * forces that regardless of the request — so it's sent as an explicit skip, * with no next key to generate; the form hides the "skip" checkbox for it. */ - async function handleArchive(missedFlag: boolean): Promise { + async function handleArchive( + missedFlag: boolean, + topicsRetries = 0, + ): Promise { if (!detail) return; archiving = true; actionError = null; + const archiveId = id; try { // Save the last "discussed" ticks first: archiving freezes them. If // that fails, its banner explains why; Archive again goes ahead. The // wait can be long enough for the page to open another anketa. - const archiveId = id; const saved = (await discussedSync?.settled()) ?? true; + // And any topic save: the carry-forward below reads the list it leaves. + const topicsSaved = (await topicsSync?.settled()) ?? true; if (id !== archiveId) return; - if (!saved) { + if (!saved || !topicsSaved) { // Archived meanwhile (the save got the archived 409), or the save // failed, whose own banner says why: either way this click didn't // archive, so say so. @@ -1552,16 +1626,21 @@ // after a save — self-initiated or, since this page now polls for // live updates, the counterpart's too) — archiving straight from a // stale snapshot would silently drop any outcome added/edited after - // this page first loaded. Re-encrypting the current list under the - // same (old) anketaKey first, then handing that fresh ciphertext to - // carryForwardOutcomes, is simpler than giving that function a - // separate already-decrypted-input code path for one caller. - const currentOutcomesBlob = await encryptBlob(allOutcomes, anketaKey); - const outcomesBlobNext = await carryForwardOutcomes( - currentOutcomesBlob, - anketaKey, + // this page first loaded. + const outcomesBlobNext = await carryForwardOutcomeItems( + allOutcomes, nextKey, ); + // The topics not yet discussed (GitHub issue #206), with the + // version of the list they're taken from: if the counterpart has + // changed it since (the list here can be a poll interval behind), + // the server refuses the archive with the current list, and the + // catch below archives again with that. This tab's own topic saves + // are already in (settled() above). + const topicsBlobNext = topicsSync + ? await carryForwardTopicItems(topicsSync.items(), nextKey) + : undefined; + const topicsVersion = topicsSync?.version; body = { ...body, @@ -1573,6 +1652,8 @@ mySealedKey: mySealedKeyNext, counterpartSealedKey: counterpartSealedKeyNext, ...(outcomesBlobNext ? { outcomesBlob: outcomesBlobNext } : {}), + ...(topicsBlobNext ? { topicsBlob: topicsBlobNext } : {}), + ...(topicsVersion === undefined ? {} : { topicsVersion }), }; } @@ -1580,6 +1661,31 @@ missed = missedFlag; enterArchivedState(); } catch (error) { + // The page has opened another anketa meanwhile: nothing here is its. + if (id !== archiveId) return; + const newerTopics = versionConflictBody( + error, + 'topicsBlob', + 'topicsVersion', + ); + if (newerTopics && anketaKey && topicsSync && topicsRetries < 3) { + // The topics changed after the carry-forward was built: take the + // list the refusal carries and archive again. Still not archived, so + // this click's choices stand. + const sync = topicsSync; + try { + const items = await decryptTopics(newerTopics.blob, anketaKey); + if (sync !== topicsSync) return; + sync.applyRemote(items, newerTopics.version); + } catch (decryptError) { + console.error(decryptError); + actionError = $_('anketa.errorArchive'); + return; + } + // Handles its own errors, like this call. + await handleArchive(missedFlag, topicsRetries + 1); + return; + } const alreadyArchived = error instanceof ApiError && error.status === 409 ? (error.body as { @@ -1654,8 +1760,9 @@ } /** - * Shared reapply-on-conflict update for the anketa's three optimistic- - * concurrency blobs (comments, outcomes, goal checkpoints — see blobSync.ts): + * Shared reapply-on-conflict update for the anketa's optimistic- + * concurrency list blobs (comments, outcomes, goal checkpoints — see + * blobSync.ts): * refetch, apply the caller's mutation, save, and on a 409 retry once * against whatever the conflict response carries under the same field names. * @@ -1669,6 +1776,10 @@ * wide gate, see anyCommentThreadBusy), that catch-up never runs, so every * subsequent tick keeps re-fetching the full anketa for nothing, for as * long as anything anywhere stays busy. + * + * Bound to the anketa and key it started with: if the page has opened + * another anketa by the time a request returns, nothing is sent to or + * applied on that one, and the caller gets undefined. */ async function updateField( blobKey: 'commentsBlob' | 'outcomesBlob' | 'goalCheckpointsBlob', @@ -1677,11 +1788,13 @@ endpoint: string, apply: (current: T) => T, ): Promise<{ items: T; version: number } | undefined> { - if (!anketaKey) return undefined; - const fresh = await apiGet(`/api/anketas/${id}`); + const key = anketaKey; + const anketaId = id; + if (!key) return undefined; + const fresh = await apiGet(`/api/anketas/${anketaId}`); let savedVersion = fresh[versionKey]; const items = await updateBlobWithRetry( - anketaKey, + key, { blob: fresh[blobKey], version: fresh[versionKey] }, apply, async (blob, expectedVersion) => { @@ -1693,21 +1806,17 @@ // response-shape change) leaves the pre-save version in place // instead of silently writing undefined/NaN into it. const result = await apiPut>>( - `/api/anketas/${id}/${endpoint}`, + `/api/anketas/${anketaId}/${endpoint}`, { blob, expectedVersion }, ); savedVersion = result[versionKey] ?? savedVersion; }, (error) => { - if (!(error instanceof ApiError) || error.status !== 409) - return undefined; - const conflict = error.body as Record; - return { - blob: conflict[blobKey] as string | null, - version: conflict[versionKey] as number, - }; + // Not the "archived" 409, which carries no version to retry against. + return versionConflictBody(error, blobKey, versionKey) ?? undefined; }, ); + if (id !== anketaId) return undefined; return { items, version: savedVersion }; } @@ -1879,6 +1988,24 @@ {/key}
+ + {#key id} + {#if topicsSync} + {@const sync = topicsSync} + sync.update(apply)} + /> + {/if} + {/key} + {#if customQuestions.status === 'failed'}