Commit Graph

387 Commits

Author SHA1 Message Date
Marcel
e6d55e47b1 feat(thumbnails): persist pageCount from PDDocument / 1 for images
Groups the first-page BufferedImage and the source's total page count
into a SourcePreview record so both values travel through generate()
together. PDFs get pdf.getNumberOfPages(); image uploads always get 1
(a scan is one page from the user's perspective). The page badge on
the thumbnail row uses this value to show "1 / N" for multi-page
letters without a separate round-trip.

Refs #305

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
b48533be26 feat(thumbnails): persist thumbnailAspect from source image dimensions
Computes aspect at generate-time from the loaded BufferedImage: w/h
above 1.1 → LANDSCAPE, otherwise PORTRAIT. The threshold keeps
near-square A4 scans in the portrait tile (ratio ≈ 1.0) rather than
flipping to landscape on a rounding error. Also hardens the pipeline
with an explicit dimension guard so width=0 / height=0 edge cases fail
cleanly instead of dividing by zero when the aspect is computed.

Refs #305

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
7fc517b787 test(thumbnails): lock corrupt-image + corrupt-pdf failure paths
Both cases already return FAILED via the existing catch-Exception blocks
in readSourceImage. Pinning the behavior with regression tests before
thumbnailAspect and pageCount computation is added, so a future
refactor that removes the safety net is caught at compile/test time.

Refs #305

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
8ac996f6b2 feat(documents): expose thumbnailAspect + pageCount on Document entity
Adds ThumbnailAspect enum (PORTRAIT | LANDSCAPE) and maps the two
nullable columns from V53 as JPA fields so ThumbnailService can
populate them and the API can return them unchanged to the frontend.

Refs #305

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
55557047de feat(documents): V53 add thumbnail_aspect + page_count columns
Adds two nullable metadata columns to documents, populated by
ThumbnailService when it generates the JPEG preview. Both remain null
until the existing admin backfill endpoint reruns the service.

Refs #305

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
f0f9753c42 refactor(backend): split ThumbnailService.generate into stages with distinct logs
Addresses @felixbrandt — fix(backend): "the two try blocks in generate()
overlap — a save failure logs 'generation failed' even though the
thumbnail is already in S3 as an orphan".

generate() now orchestrates four stages, each in its own try+log:
  readSourceImage / encodeThumbnail / uploadToStorage / persistThumbnailMetadata

persistThumbnailMetadata emits the distinct "orphaned in storage as <key>"
log line so an operator can see database-side failures after the upload
completed. The deterministic key ensures the next run overwrites cleanly,
so the orphan is self-healing.

Also extracts THUMBNAIL_KEY_PREFIX/SUFFIX constants with a comment
explaining the deterministic-overwrite contract.

Adds test: generate_returnsFailed_whenPersistThrows_butUploadSucceeded.

Refs #307

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 23:01:50 +02:00
Marcel
547db2fd02 test(backend): add ThumbnailServiceIntegrationTest against real MinIO
Spins up a MinIO container (Testcontainers GenericContainer) alongside
the existing PostgresContainerConfig, uploads a sample PDF, runs the
real ThumbnailService, and reads the resulting JPEG back from the
object store. Catches S3 signing / path-style access issues a mocked
S3Client wouldn't — justifies the CI cost (~45s) per walkthrough T9b.

Refs #307

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 22:21:02 +02:00
Marcel
f11a29504a feat(backend): add GET /api/documents/{id}/thumbnail endpoint
Streams the JPEG thumbnail from S3 with Cache-Control: private,
max-age=31536000, immutable — `private` (not `public`) prevents
shared caches from leaking one user's thumbnail to another (CWE-525).
`immutable` is safe because the URL carries ?v=<thumbnailGeneratedAt>
as a cache-buster that changes whenever the file is replaced.

Authentication falls back to the global .anyRequest().authenticated()
rule, matching the existing /file endpoint's permission model.

Refs #307

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 22:10:01 +02:00
Marcel
323ec1ec54 feat(backend): add AdminController endpoints for thumbnail backfill
- POST /api/admin/generate-thumbnails  → triggers async backfill, 202
- GET  /api/admin/thumbnail-status     → returns current BackfillStatus

Both gated by the class-level @RequirePermission(Permission.ADMIN).
Shape and polling semantics mirror the mass-import endpoints.

Refs #307

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 22:05:47 +02:00
Marcel
09fc871756 feat(backend): add ThumbnailBackfillService for regenerating missing thumbnails
Sequentially processes all documents with a file but no thumbnail and
tallies processed / skipped / failed counts. Runs on thumbnailExecutor
so it shares back-pressure with live upload thumbnails but can never
saturate them (single-threaded loop).

Concurrent start rejected with THUMBNAIL_BACKFILL_ALREADY_RUNNING.
Emits a structured summary log line on completion for operator
visibility.

Refs #307

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 22:02:20 +02:00
Marcel
0344a0c7ff feat(backend): dispatch thumbnail generation from MassImportService
ODS/Excel imports that actually upload a file (file.isPresent()) now
trigger thumbnail generation alongside hash/metadata. Metadata-only
import rows produce no thumbnail — nothing to render.

Refs #307

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 21:58:24 +02:00
Marcel
7d0e13c591 feat(backend): dispatch thumbnail generation from DocumentService upload paths
All four upload code paths (storeDocument, createDocument, updateDocument,
attachFile) now call thumbnailAsyncRunner.dispatchAfterCommit(id) after
the document save. createDocument and updateDocument only dispatch when a
file was actually provided/replaced.

The dispatch is afterCommit-safe: if the surrounding @Transactional
method rolls back, no thumbnail is generated for a document that never
reached the DB.

Refs #307

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 21:57:36 +02:00
Marcel
3b7ef6117e feat(backend): add ThumbnailAsyncRunner with afterCommit dispatch and timeout
Bridges @Transactional upload paths to the async thumbnail pipeline.
dispatchAfterCommit registers a TransactionSynchronization so the async
task only fires after the surrounding commit (and is silently skipped
on rollback) — mirrors the AuditService.logAfterCommit pattern.

generateAsync wraps the full ThumbnailService.generate call in a 30s
watchdog so a hung PDFBox render cannot occupy a thumbnailExecutor slot
indefinitely.

Refs #307

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 21:49:26 +02:00
Marcel
955c497ba0 feat(backend): add ThumbnailService for PDF and image thumbnails
Renders a 240px-wide JPEG (quality 85) from either a PDF first page
via PDFBox or a JPEG/PNG/TIFF scan via ImageIO, then uploads to
S3 under thumbnails/{docId}.jpg and updates the Document entity.

Scaling uses Graphics2D.drawImage with VALUE_INTERPOLATION_BILINEAR
(not deprecated Image.getScaledInstance). Source is streamed via
FileService.downloadFileStream to avoid buffering 50MB PDFs.

Never throws — returns Outcome.SKIPPED for unsupported content types
and Outcome.FAILED for rendering/upload errors so the backfill can
tally them without aborting the run.

Refs #307

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 21:46:08 +02:00
Marcel
0bb18c6789 feat(backend): add thumbnailExecutor bean for isolated thumbnail workload
Dedicated thread pool (core=1, max=2, queue=200) with CallerRunsPolicy
for back-pressure. Keeps thumbnail rendering off the shared taskExecutor
used by OCR and out of the AbortPolicy queue that drops work on overflow.
Quick-upload batches (15+ files) now apply back-pressure instead of
silently dropping thumbnail jobs.

Refs #307

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 21:42:05 +02:00
Marcel
07019f54e8 feat(backend): add FileService.downloadFileStream for memory-efficient reads
Thumbnail generation will call this for PDFs up to 50 MB — loading the
full byte[] via downloadFileBytes would cause real memory pressure on
the single-VPS deploy. Stream-based reads let PDFBox parse the first
page without holding the whole file in heap.

Refs #307

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 21:41:15 +02:00
Marcel
a2333975f9 feat(backend): add THUMBNAIL_BACKFILL_ALREADY_RUNNING error code
Mirrors the IMPORT_ALREADY_RUNNING pattern for the concurrent-start
guard in ThumbnailBackfillService.

Refs #307

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 21:36:06 +02:00
Marcel
b8962f4337 feat(backend): add DocumentRepository finder for thumbnail backfill
Adds findByFilePathIsNotNullAndThumbnailKeyIsNull() used by the
upcoming ThumbnailBackfillService to locate documents that have a
file attached but no thumbnail yet.

Refs #307

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 21:35:08 +02:00
Marcel
6cf0601590 feat(db): add thumbnail_key and thumbnail_generated_at to documents
Adds two nullable columns to the documents table and their JPA mappings
on the Document entity. Both are left out of the OpenAPI required-mode
schema so the generated TypeScript type exposes them as optional.

Refs #307

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 21:34:03 +02:00
Marcel
ba0f9bb325 refactor(test): fix unchecked cast and derive ALL_ELIGIBLE_KINDS from enum
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m36s
CI / OCR Service Tests (pull_request) Successful in 29s
CI / Backend Unit Tests (pull_request) Failing after 2m46s
CI / Unit & Component Tests (push) Failing after 2m43s
CI / OCR Service Tests (push) Successful in 31s
CI / Backend Unit Tests (push) Failing after 2m51s
- Replace any(Set.class) with any() to eliminate the raw-type unchecked
  cast in DashboardControllerTest
- Derive ALL_ELIGIBLE_KINDS from AuditKind.ROLLUP_ELIGIBLE.stream() so
  the integration test constant stays in sync with the production constant
  automatically when new kinds are added

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 22:45:23 +02:00
Marcel
c0e52b8432 refactor(audit): move AuditLogQueryServiceTest to audit package
Addresses review concern: the test lived in the dashboard package but
tests the audit domain service. Package-by-feature convention requires
audit tests to live in the audit package.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 22:44:37 +02:00
Marcel
99c3106835 feat(openapi): expose kinds param in dashboard activity spec
Added @Parameter annotation so SpringDoc renders kinds as an
enum-array query param; regenerated TypeScript API types.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 21:19:16 +02:00
Marcel
8d16e4d975 feat(dashboard): add kinds param to GET /api/dashboard/activity
Spring auto-converts ?kinds=FILE_UPLOADED,TEXT_SAVED to Set<AuditKind>.
Absent or empty kinds defaults to ROLLUP_ELIGIBLE. Unknown value → 400.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 21:10:43 +02:00
Marcel
571ecfc626 test(dashboard): guard getPulse always uses 2-arg findActivityFeed
Regression test: getPulse must never receive a kinds filter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 21:05:43 +02:00
Marcel
475e16a85d feat(audit): add findActivityFeed(UUID, int, Set<AuditKind>) overload
Two-arg variant delegates to three-arg with ROLLUP_ELIGIBLE so
existing callers (getPulse) are unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 21:03:39 +02:00
Marcel
fe7a8ed9ad feat(audit): add kinds param to findRolledUpActivityFeed
Filter is applied at the innermost events CTE to reduce rows
entering the LAG/session CTEs. Existing callers pass ROLLUP_ELIGIBLE
by default so behaviour is unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 20:59:56 +02:00
Marcel
d700b0a948 refactor(audit): add ROLLUP_ELIGIBLE constant to AuditKind
Single source of truth for the six kinds eligible for the activity rollup feed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 20:53:14 +02:00
Marcel
f50a746619 feat(dashboard): enrich activity feed DTO with commentId + annotationId
ActivityFeedItemDTO gains nullable commentId and annotationId fields.
DashboardService.getActivity forwards commentId from the projection
and batch-resolves annotationId via the new
CommentService.findAnnotationIdsByIds lookup. Both remain null for
non-comment kinds, so the bulk lookup is skipped entirely when the
feed has no comment rows.

Refs #300.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 17:11:03 +02:00
Marcel
40260be07a feat(comment): add findAnnotationIdsByIds batch lookup
Exposes a CommentService method that maps a collection of
commentIds to their annotationIds via commentRepository.findAllById.
Unknown comments and comments with null annotationId are omitted.
Used by the dashboard activity feed enrichment to supply the
deep-link annotationId without growing the audit SQL query.

Refs #300.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 17:04:38 +02:00
Marcel
b9f5ec22aa feat(audit): expose commentId on rolled-up activity feed projection
Adds getCommentId() to ActivityFeedRow and selects
(ag.payload->>'commentId')::uuid from findRolledUpActivityFeed so
chronik consumers can build deep-link URLs for COMMENT_ADDED and
MENTION_CREATED events. Null for other kinds.

Refs #300.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 17:00:36 +02:00
Marcel
bc69e8ff1e refactor(comment): drop dead document and annotation comment APIs
Only block comments are surfaced by the frontend now. The document-level
and annotation-level comment endpoints and service methods existed but
had no consumer. Remove them along with their repository queries and
test coverage so the surface area matches the actual feature set.

Shared edit, delete, and block reply endpoints stay. postBlockComment
now carries the authorName/mention/audit behaviors previously tested
through the dropped postComment method, so those behaviors remain
covered by the block-scoped test suite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 13:27:10 +02:00
Marcel
13732ab96b fix(db): V51 backfills annotation_id on block comments and notifications
Previously issued block-comment notifications were stored with
annotation_id=NULL because CommentService.postBlockComment did not
populate DocumentComment.annotationId. Now that the code fix is in
place, existing rows need to be filled in so legacy notifications
can also carry the query param that the frontend deep-link flow
expects.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 13:13:13 +02:00
Marcel
4658852281 fix(comment): populate annotationId on block comments from the block
postBlockComment now looks up the block via TranscriptionService and
sets comment.annotationId from block.getAnnotationId(). This closes
the upstream root cause of issue #276, where notifications for block
comments were stored with annotationId=null, breaking the notification
deep-link flow on the document detail page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 13:09:30 +02:00
Marcel
edb4e54df2 fix(audit): backfill COMMENT_ADDED and MENTION_CREATED events
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m40s
CI / OCR Service Tests (push) Successful in 35s
CI / Backend Unit Tests (push) Failing after 2m54s
Comments created before audit logging was added in 428c63a2 have no
corresponding audit_log rows, so the Chronik activity feed (which
reads exclusively from audit_log) cannot surface them in "Alle" or
"Für dich", even though the fix from #295 is wired up correctly.
V50 inserts the missing events idempotently from document_comments
and comment_mentions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 12:08:45 +02:00
Marcel
1fd2fab9a1 refactor(audit): document MIN invariant in aggregated CTE comment
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m43s
CI / OCR Service Tests (push) Successful in 32s
CI / Backend Unit Tests (push) Failing after 2m52s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 09:00:23 +02:00
Marcel
53352be997 test(audit): document youParticipated graceful degradation when commentId absent
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 09:00:23 +02:00
Marcel
dc027cdf40 test(audit): add cross-user scoping regression for youParticipated
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 09:00:23 +02:00
Marcel
5b3fb8fb3f refactor(test): use ObjectMapper for payload JSON in audit test helper
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 09:00:23 +02:00
Marcel
388fc5397c feat(dashboard): expose youParticipated in ActivityFeedItemDTO
Add youParticipated field to the DTO record and wire row.isYouParticipated()
through DashboardService.getActivity().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 09:00:23 +02:00
Marcel
454e5f7517 feat(audit): surface youParticipated via REPLY notification subquery
Add you_participated correlated subquery to findRolledUpActivityFeed.
Carries payload through the aggregated CTE via MIN(payload::text)::jsonb
so the commentId can be matched against notifications.reference_id.
Uses CAST(:currentUserId AS uuid) to avoid Spring Data JPA misparsin ::
cast syntax as a parameter name.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 09:00:23 +02:00
Marcel
9c52e1921f test(audit): add youParticipated and youMentioned repository tests
Add isYouParticipated() to ActivityFeedRow interface and cover four
behaviours in AuditLogQueryRepositoryRolledUpTest: youParticipated
true/false and retroactive youMentioned true/false coverage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 09:00:23 +02:00
Marcel
47859e5a9b feat(documents): retrofit WRITE_ALL guard on /incomplete-count + /incomplete/next
Closes the CWE-285 gap Nora flagged on issue #296: both endpoints expose
enrichment-queue information that only writers should see. Brings them in
line with the new /incomplete list endpoint and every other write-path
under DocumentController.

Frontend callers (/enrich/[id]/+page.server.ts) already gate on WRITE_ALL
at the route level, so no client-side change is needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 21:14:24 +02:00
Marcel
758c708766 test(documents): lock /incomplete size cap at 200
Regression test proving the controller clamps client-supplied size
values server-side, closing the unbounded-limit concern Markus flagged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 21:09:10 +02:00
Marcel
2c5cfcedbc feat(documents): gate /incomplete behind WRITE_ALL permission
Only users who can enrich documents should see the queue.
Mirrors the frontend guard in enrich/+page.server.ts and closes the
CWE-285 gap Nora flagged on issue #296.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 21:05:35 +02:00
Marcel
bc3a268f66 feat(documents): re-add GET /api/documents/incomplete
Restores the list endpoint removed in ddd811c6 and caps size at 200.
The dashboard enrichment block (issue #296) and /enrich page both
consume it; /enrich was silently 404ing since the deletion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 21:01:54 +02:00
Marcel
d2fc452c1a feat(dto): add uploadedAt to IncompleteDocumentDTO
Mapper populates uploadedAt from Document.createdAt so the dashboard
enrichment block can show a relative-time meta line ("vor 2 Min.")
per issue #296.

LocalDateTime matches the convention used by NotificationDTO,
DocumentVersionSummary and InviteListItemDTO.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:58:15 +02:00
Marcel
b8ffd81b7e test(audit): add regression proving STATUS_CHANGED/METADATA_UPDATED are excluded
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m41s
CI / OCR Service Tests (push) Successful in 30s
CI / Backend Unit Tests (push) Failing after 2m52s
Cheap insurance for the day someone widens the WHERE clause in
findRolledUpActivityFeed. Suggested by Sara in PR #288 review cycle 1.

Inserts two non-eligible events alongside one TEXT_SAVED and asserts the feed
returns the TEXT_SAVED row only.

Part of #285.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:38:10 +02:00
Marcel
101f5b2a6a feat(audit): add V49 rollup covering index + raise /api/dashboard/activity cap to 40
- V49__add_audit_log_rollup_index.sql: partial covering index on
  (actor_id, document_id, kind, happened_at DESC) filtered by the 6 rollup
  kinds. Matches the WHERE clause of findRolledUpActivityFeed exactly so the
  session-grouping window scan is index-backed.
- DashboardController: clamp limit to 40 (was 20). Chronik requests up to 40
  activity items per page; dashboard side-rail still passes 7.

Part of #285.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:38:10 +02:00
Marcel
eda30f53fa refactor(audit): rename findDedupedActivityFeed to findRolledUpActivityFeed
The method no longer deduplicates by hour-trunc — it performs session-style
rollup via LAG()+120-min gap. Rename aligns the public name with the
behavior.

Part of #285.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:38:10 +02:00
Marcel
feefa682b3 feat(audit): replace hour-trunc dedupe with LAG() session rollup (120-min gap)
Rewrites the activity feed query to group consecutive events on the same
(actor, document, kind) into sessions separated by >120 min gaps. A session
becomes one row with count = events-in-session and happenedAtUntil = last
event timestamp. Singletons keep count=1 / happenedAtUntil=null.

Algorithm: LAG() to get the previous event's timestamp in the same partition,
mark a new session when gap > 7200s, then SUM() over an unbounded preceding
window yields a running session_id. Aggregation groups by session_id.

COMMENT_ADDED and MENTION_CREATED always start a new session — these kinds
never roll up so each event stays its own row.

Also adds BLOCK_REVIEWED to the eligible-kinds WHERE clause (Chronik spec §02)
so reviewed blocks appear in the activity feed.

Five new integration tests cover combine-within-2h, split-at-boundary,
no-hard-cap-on-long-session, never-rolls-up-comments/mentions, and the
count/happenedAtUntil contract on both singletons and rollups.

Part of #285.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:38:10 +02:00