Skip to content

✨(backend) per-user read tracking for shared mailboxes - #770

Open
nicolasaunai wants to merge 2 commits into
suitenumerique:mainfrom
nicolasaunai:feat/message-read-per-user
Open

✨(backend) per-user read tracking for shared mailboxes#770
nicolasaunai wants to merge 2 commits into
suitenumerique:mainfrom
nicolasaunai:feat/message-read-per-user

Conversation

@nicolasaunai

@nicolasaunai nicolasaunai commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Adds a MessageRead model to track which user has read which message in shared mailboxes. The existing read/unread flag (based on ThreadAccess.read_at) is unchanged, but each message now exposes a read_by field listing who has read it.

Backend

  • New MessageRead model (message, user, read_at) with unique_together
  • Migration 0035 for messages_messageread table
  • MessageSerializer.read_by — list of {user_id, user_name, read_at}
  • ChangeFlagView creates/deletes MessageRead on mark-as-read/unread
  • Prefetch reads in MessageViewSet (select_related user) to avoid N+1
  • ReadOnly inline in MessageAdmin

Frontend

  • Auto-generated MessageReadByItem type via Orval
  • "Vu par" display below each thread message, showing reader names

Summary by CodeRabbit

  • New Features
    • Messages now show which participants have read them.
    • Read status is tracked individually for each message and reader.
    • Marking a thread as read or unread updates message-level read indicators.
    • Administrators can view message read records in the admin interface.
  • Improvements
    • Message responses now include reader names, IDs, and read timestamps.

Adds a MessageRead model to track which user has read which message in
shared mailboxes. The existing read/unread flag (based on
ThreadAccess.read_at) is unchanged, but each message now exposes a
read_by field listing who has read it.

- New MessageRead model (message, user, read_at)
- Migration 0035 for messages_messageread table
- Flag endpoint creates/deletes MessageRead on mark-as-read/unread
- read_by field in MessageSerializer
- Prefetch reads in MessageViewSet to avoid N+1
- ReadOnly inline in MessageAdmin
- Frontend: ReadByUser type + 'Vu par' display below each message
The read_by serializer field was missing from the generated OpenAPI schema
and frontend client, causing the CI API-update check to fail.

- Regenerated openapi.json with Message.read_by
- Generated MessageReadByItem type from schema
- Updated Message model import to use auto-generated type
- Removed hand-written ReadByUser type from message.ts

Signed-off-by: Nicolas Aunai <nicolas.aunai@lpp.polytechnique.fr>
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds per-user message read tracking, synchronizes records from read-state updates, exposes reader attribution through the message API, displays it in thread messages, and adds read-only Django admin visibility.

Changes

Message read tracking

Layer / File(s) Summary
MessageRead persistence
src/backend/core/models.py, src/backend/core/migrations/0035_messageread.py
Defines per-user message read records with timestamps, cascading relationships, and (message, user) uniqueness.
Read state synchronization
src/backend/core/api/viewsets/flag.py
Validates read_at, preserves the raw value for thread access, and creates or deletes matching MessageRead records.
Message API read attribution
src/backend/core/api/serializers.py, src/backend/core/api/viewsets/message.py, src/backend/core/api/openapi.json
Adds the read-only read_by response field, documents its shape, and prefetches readers with their users.
Read records in Django admin
src/backend/core/admin.py
Adds a non-addable, non-deletable, read-only MessageRead inline to the message admin view.
Thread read indicator
src/frontend/src/features/layouts/components/thread-view/components/thread-message/index.tsx, src/frontend/src/features/layouts/components/thread-view/components/thread-message/_index.scss
Displays reader names and styles the read-by indicator when message readers are present.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant ChangeFlagView
  participant MessageRead
  participant MessageViewSet
  participant ThreadMessage
  User->>ChangeFlagView: submit read_at
  ChangeFlagView->>MessageRead: create or delete read records
  MessageViewSet->>MessageRead: prefetch readers
  MessageViewSet->>ThreadMessage: return message with read_by
  ThreadMessage->>User: display reader names
Loading

Suggested reviewers: jbpenrath

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding per-user read tracking for shared mailboxes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/backend/core/api/serializers.py`:
- Around line 1151-1163: Update the read_by schema in
src/backend/core/api/serializers.py#L1151-L1163 by adding an item-level required
declaration for user_id, user_name, and read_at, then regenerate
src/backend/core/api/openapi.json#L9518-L9536 so the generated item schema
includes the same required fields.

In `@src/backend/core/api/viewsets/flag.py`:
- Around line 393-421: The read_at branch in the message-read update flow must
remove this user’s MessageRead rows for messages with created_at greater than
the lowered watermark before creating missing rows. Update the logic around the
existing messages_to_mark and bulk_create operations to delete those
out-of-watermark reads for thread_pks and user, while preserving creation of
missing reads up to read_at.

In `@src/backend/core/migrations/0035_messageread.py`:
- Around line 17-22: Wrap the generated field declarations in the migration’s
MessageRead model, especially the UUIDField, DateTimeField, and ForeignKey
calls, so every line stays within the repository’s 100-character limit while
preserving all existing field options and migration behavior.

In
`@src/frontend/src/features/layouts/components/thread-view/components/thread-message/index.tsx`:
- Around line 395-399: Replace the hard-coded 'Vu par ' label in the thread
message read-by block with the component’s existing t(...) localization helper,
using the appropriate translation key and preserving the mapped reader names and
spacing.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 98092042-7777-44e0-a4b0-b937779d3fc0

📥 Commits

Reviewing files that changed from the base of the PR and between cf0b70e and 728544d.

⛔ Files ignored due to path filters (3)
  • src/frontend/src/features/api/gen/models/index.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/message.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/message_read_by_item.ts is excluded by !**/gen/**
📒 Files selected for processing (9)
  • src/backend/core/admin.py
  • src/backend/core/api/openapi.json
  • src/backend/core/api/serializers.py
  • src/backend/core/api/viewsets/flag.py
  • src/backend/core/api/viewsets/message.py
  • src/backend/core/migrations/0035_messageread.py
  • src/backend/core/models.py
  • src/frontend/src/features/layouts/components/thread-view/components/thread-message/_index.scss
  • src/frontend/src/features/layouts/components/thread-view/components/thread-message/index.tsx

Comment on lines +1151 to +1163
@extend_schema_field(
{
"type": "array",
"items": {
"type": "object",
"properties": {
"user_id": {"type": "string", "format": "uuid"},
"user_name": {"type": "string"},
"read_at": {"type": "string", "format": "date-time"},
},
},
}
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== serializer around read_by annotation =="
sed -n '1120,1185p' src/backend/core/api/serializers.py

echo
echo "== openapi read_by section =="
sed -n '9495,9545p' src/backend/core/api/openapi.json

echo
echo "== definitions/usages of get_read_by and read_by field =="
rg -n "def get_read_by|read_by|get_read_by|annotate.*read_by" src/backend/core -S

Repository: suitenumerique/messages

Length of output: 5740


Declare the read_by item fields as required.

get_read_by() always returns user_id, user_name, and read_at, but the schema marks them as optional. Add the item-level required array and regenerate the OpenAPI artifact.

  • src/backend/core/api/serializers.py#L1151-L1163: add required: ["user_id", "user_name", "read_at"] to the item object.
  • src/backend/core/api/openapi.json#L9518-L9536: regenerate the schema so the item object includes the same required fields.
📍 Affects 2 files
  • src/backend/core/api/serializers.py#L1151-L1163 (this comment)
  • src/backend/core/api/openapi.json#L9518-L9536
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/core/api/serializers.py` around lines 1151 - 1163, Update the
read_by schema in src/backend/core/api/serializers.py#L1151-L1163 by adding an
item-level required declaration for user_id, user_name, and read_at, then
regenerate src/backend/core/api/openapi.json#L9518-L9536 so the generated item
schema includes the same required fields.

Comment on lines +393 to +421
if read_at is not None:
# Mark all messages up to read_at as read by this user
messages_to_mark = list(
models.Message.objects.filter(
thread_id__in=thread_pks,
created_at__lte=read_at,
)
.exclude(
reads__user=user,
)
.values_list("id", flat=True)
)
if messages_to_mark:
models.MessageRead.objects.bulk_create(
[
models.MessageRead(
message_id=mid, user=user, read_at=read_at
)
for mid in messages_to_mark
],
ignore_conflicts=True,
batch_size=500,
)
else:
# Mark as unread: remove MessageRead for this user in these threads
models.MessageRead.objects.filter(
message__thread_id__in=thread_pks,
user=user,
).delete()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Remove reads beyond a lowered watermark.

When read_at moves from a later timestamp to an earlier one, this only creates missing rows; it retains this user’s MessageRead rows for messages after the new watermark. ThreadAccess.read_at then marks those messages unread while read_by still reports them as read. Delete this user’s rows with message__created_at__gt=read_at in these threads before creating missing rows.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/core/api/viewsets/flag.py` around lines 393 - 421, The read_at
branch in the message-read update flow must remove this user’s MessageRead rows
for messages with created_at greater than the lowered watermark before creating
missing rows. Update the logic around the existing messages_to_mark and
bulk_create operations to delete those out-of-watermark reads for thread_pks and
user, while preserving creation of missing reads up to read_at.

Comment on lines +17 to +22
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('read_at', models.DateTimeField(db_index=True, default=django.utils.timezone.now, verbose_name='read at')),
('message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reads', to='core.message')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='message_reads', to='core.user')),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Wrap the generated field declarations.

Lines 17–22 exceed the repository’s 100-character limit. As per coding guidelines, src/backend/**/*.py must follow Django/PEP 8 with a 100-character line limit.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/core/migrations/0035_messageread.py` around lines 17 - 22, Wrap
the generated field declarations in the migration’s MessageRead model,
especially the UUIDField, DateTimeField, and ForeignKey calls, so every line
stays within the repository’s 100-character limit while preserving all existing
field options and migration behavior.

Source: Coding guidelines

Comment on lines +395 to +399
{message.read_by && message.read_by.length > 0 && (
<div className="thread-message__read-by">
{'Vu par '}
{message.read_by.map((r) => r.user_name).join(', ')}
</div>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Localize the read-by label.

'Vu par ' is hard-coded even though this component already uses t(...); users in other locales will see a French label.

Proposed fix
-                                {'Vu par '}
+                                {t('Seen by')}{' '}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{message.read_by && message.read_by.length > 0 && (
<div className="thread-message__read-by">
{'Vu par '}
{message.read_by.map((r) => r.user_name).join(', ')}
</div>
{message.read_by && message.read_by.length > 0 && (
<div className="thread-message__read-by">
{t('Seen by')}{' '}
{message.read_by.map((r) => r.user_name).join(', ')}
</div>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/frontend/src/features/layouts/components/thread-view/components/thread-message/index.tsx`
around lines 395 - 399, Replace the hard-coded 'Vu par ' label in the thread
message read-by block with the component’s existing t(...) localization helper,
using the appropriate translation key and preserving the mapped reader names and
spacing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant