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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
362 changes: 199 additions & 163 deletions backend/fixtures/demo-seed.json

Large diffs are not rendered by default.

30 changes: 30 additions & 0 deletions backend/migrations-mysql/Version20261004104608.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

namespace App\Migrations\MySQL;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

/**
* Hand-written, MySQL-native counterpart to the SQLite Version20261004104608 migration —
* adds anketas.topicsBlob/topicsVersion (GitHub issue #206).
*/
final class Version20261004104608 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add anketas.topicsBlob/topicsVersion for the shared topics list';
}

public function up(Schema $schema): void
{
$this->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');
}
}
41 changes: 41 additions & 0 deletions backend/migrations/Version20261004104608.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

declare(strict_types=1);

namespace App\Migrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

/**
* Adds anketas.topicsBlob/topicsVersion (GitHub issue #206, see Anketa::$topicsBlob):
* the shared "Topics to discuss" list, encrypted under anketaKey.
*/
final class Version20261004104608 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add anketas.topicsBlob/topicsVersion for the shared topics list';
}

public function up(Schema $schema): void
{
$this->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)');
}
}
42 changes: 37 additions & 5 deletions backend/src/Anketa/AnketaLifecycleService.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@
}

/**
* 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.
Expand All @@ -44,13 +44,15 @@
string $managerSealedKey,
int $periodicityDays,
?string $outcomesBlob = null,
?string $topicsBlob = null,
?Anketa $carryFrom = null,
string $templateKey = Anketa::DEFAULT_TEMPLATE_KEY,
bool $oneOff = false,
?CustomTemplateVersion $customTemplateVersion = null,
): Anketa {
if ($oneOff) {
$outcomesBlob = null;
$topicsBlob = null;
$carryFrom = null;
}

Expand All @@ -69,6 +71,9 @@
if (null !== $outcomesBlob) {
$anketa->seedOutcomes($outcomesBlob);
}
if (null !== $topicsBlob) {
$anketa->seedTopics($topicsBlob);
}

$this->entityManager->persist($anketa);

Expand Down Expand Up @@ -101,6 +106,7 @@
string $managerSealedKey,
int $periodicityDays,
?string $outcomesBlob = null,
?string $topicsBlob = null,
?Anketa $carryFrom = null,
?User $creator = null,
string $templateKey = Anketa::DEFAULT_TEMPLATE_KEY,
Expand All @@ -115,6 +121,7 @@
managerSealedKey: $managerSealedKey,
periodicityDays: $periodicityDays,
outcomesBlob: $outcomesBlob,
topicsBlob: $topicsBlob,
carryFrom: $carryFrom,
templateKey: $templateKey,
oneOff: $oneOff,
Expand Down Expand Up @@ -150,6 +157,8 @@
* 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,
Expand All @@ -160,8 +169,10 @@
?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
Expand All @@ -174,14 +185,14 @@
// 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
Expand All @@ -205,13 +216,14 @@
$mySealedKey,
$counterpartSealedKey,
$outcomesBlob,
$topicsBlob,
$nextTemplateKey,
$nextCustomTemplateVersion,
);
});

if (false === $nextAnketa) {
throw new AnketaAlreadyArchivedException();
throw $this->archiveRefusal($anketa, $expectedTopicsVersion);
}

if (null !== $nextAnketa) {
Expand Down Expand Up @@ -288,6 +300,24 @@
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);

Check warning on line 312 in backend/src/Anketa/AnketaLifecycleService.php

View workflow job for this annotation

GitHub Actions / mutation

Escaped Mutant for Mutator "MethodCallRemoval": @@ @@ private function archiveRefusal(Anketa $anketa, ?int $expectedTopicsVersion): AnketaAlreadyArchivedException|AnketaTopicsChangedException { if (null !== $expectedTopicsVersion) { - $this->entityManager->refresh($anketa); + if (!$anketa->isArchived()) { return new AnketaTopicsChangedException(); }
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;
Expand Down Expand Up @@ -326,6 +356,7 @@
string $mySealedKey,
string $counterpartSealedKey,
?string $outcomesBlob,
?string $topicsBlob,
string $nextTemplateKey,
?CustomTemplateVersion $nextCustomTemplateVersion,
): Anketa {
Expand All @@ -342,6 +373,7 @@
managerSealedKey: $isEmployee ? $counterpartSealedKey : $mySealedKey,
periodicityDays: $periodicityDays,
outcomesBlob: $outcomesBlob,
topicsBlob: $topicsBlob,
carryFrom: $anketa,
templateKey: $nextTemplateKey,
customTemplateVersion: $nextCustomTemplateVersion,
Expand Down
Loading
Loading