Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
0eec509
refactor(banners): Replace the payment-failure evaluator with a comma…
MontaGhanmy Sep 10, 2026
3d4810c
test(banners): fail fixture loading through t instead of panicking
MontaGhanmy Sep 11, 2026
36d95f9
fix(banners): re-localize commanded banners on a language change
MontaGhanmy Sep 11, 2026
de627cb
fix(banners): apply both ends of a command's validity window
MontaGhanmy Sep 11, 2026
16cb174
refactor(banners): correct the error comment and the success log
MontaGhanmy Sep 11, 2026
4ab9f1d
docs(banners): correct the retry and dead-letter description
MontaGhanmy Sep 11, 2026
49db1bf
ref(docs): trim banners.md, remove redundant information
MontaGhanmy Sep 11, 2026
861eb4f
fix(banners): target organizations by tenant ID
MontaGhanmy Sep 11, 2026
2328c50
fix(banners): skip instances that disallow command categories
MontaGhanmy Sep 11, 2026
2816d56
fix: update banner documents to retain command state
MontaGhanmy Sep 14, 2026
3a93280
fix: update banner integration tests to use shared setup
MontaGhanmy Sep 14, 2026
a0811b1
fix: update banner fanout to continue after instance errors
MontaGhanmy Sep 14, 2026
a27af44
fix(banners): warn on skipped instances and allowlist CTA hosts
MontaGhanmy Sep 14, 2026
f5cdc7f
fix: update banner test whitespace for lint
MontaGhanmy Sep 14, 2026
c31b2b3
fix: update MinIO test image registry
MontaGhanmy Sep 14, 2026
2b20252
refactor(banners): rename the organization target to orgId
MontaGhanmy Sep 14, 2026
3d0c7d6
refactor(banners): group banner settings and allow a category wildcard
MontaGhanmy Sep 14, 2026
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
15 changes: 0 additions & 15 deletions assets/locales/en.po
Original file line number Diff line number Diff line change
Expand Up @@ -1345,18 +1345,3 @@ msgstr "AI Assistant"

msgid "Banners Quota Exceeded Text"
msgstr "You have reached your storage limit."


msgid "Banners Billing Restricted Text"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why did you remove the translations here?

The idea is that the stack knows the instance language and it will materialize the banner using that instance language.

we should just keep that system, but the events and calculations are done in cloudery

@MontaGhanmy MontaGhanmy Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The stack doesn't choose billing wording anymore, so those entries would never be read again. What we did lose is re-localization, and that's a separate thing: fixed in 36d95f94b, where the private command document keeps every locale the Cloudery sends and the refresh hook picks one again.

msgstr "We couldn't process your payment after several attempts. Some features are now limited for your organization and your team may not be able to perform certain actions.\n\nYour data remains safe and unchanged. Full access will be restored instantly once your payment is updated."

msgid "Banners Billing CTA Label"
msgstr "Update payment method"



msgid "Banners Billing Restricted Title"
msgstr "Your workspace is temporarily restricted"

msgid "Banners Billing Support Label"
msgstr "Contact support"
12 changes: 0 additions & 12 deletions assets/locales/fr.po
Original file line number Diff line number Diff line change
Expand Up @@ -1458,15 +1458,3 @@ msgstr "Voir les détails du fichier"

msgid "Banners Quota Exceeded Text"
msgstr "Vous avez atteint la limite de votre espace de stockage."

msgid "Banners Billing Restricted Title"
msgstr "Votre espace de travail est temporairement restreint"

msgid "Banners Billing Restricted Text"
msgstr "Nous n'avons pas pu traiter votre paiement après plusieurs tentatives. Certaines fonctionnalités sont désormais limitées pour votre organisation et votre équipe peut ne plus pouvoir effectuer certaines actions.\n\nVos données restent intactes et en sécurité. L'accès complet sera rétabli dès la mise à jour de votre paiement."

msgid "Banners Billing CTA Label"
msgstr "Mettre à jour le moyen de paiement"

msgid "Banners Billing Support Label"
msgstr "Contacter le support"
12 changes: 0 additions & 12 deletions assets/locales/ru.po
Original file line number Diff line number Diff line change
Expand Up @@ -1406,15 +1406,3 @@ msgstr "Someone shared a folder with you:"

msgid "Banners Quota Exceeded Text"
msgstr "Вы достигли лимита хранилища."

msgid "Banners Billing Restricted Title"
msgstr "Ваше рабочее пространство временно ограничено"

msgid "Banners Billing Restricted Text"
msgstr "Нам не удалось обработать ваш платёж после нескольких попыток. Некоторые функции теперь ограничены для вашей организации, и ваша команда может не иметь возможности выполнять определённые действия.\n\nВаши данные в безопасности и не изменены. Полный доступ будет восстановлен сразу после обновления платежа."

msgid "Banners Billing CTA Label"
msgstr "Обновить способ оплаты"

msgid "Banners Billing Support Label"
msgstr "Связаться со службой поддержки"
12 changes: 0 additions & 12 deletions assets/locales/vi.po
Original file line number Diff line number Diff line change
Expand Up @@ -1378,15 +1378,3 @@ msgstr "Someone shared a folder with you:"

msgid "Banners Quota Exceeded Text"
msgstr "Bạn đã đạt đến giới hạn dung lượng lưu trữ."

msgid "Banners Billing Restricted Title"
msgstr "Không gian làm việc của bạn tạm thời bị hạn chế"

msgid "Banners Billing Restricted Text"
msgstr "Chúng tôi không thể xử lý khoản thanh toán của bạn sau nhiều lần thử. Một số tính năng hiện bị hạn chế đối với tổ chức của bạn và nhóm của bạn có thể không thực hiện được một số thao tác.\n\nDữ liệu của bạn vẫn an toàn và không thay đổi. Quyền truy cập đầy đủ sẽ được khôi phục ngay khi khoản thanh toán của bạn được cập nhật."

msgid "Banners Billing CTA Label"
msgstr "Cập nhật phương thức thanh toán"

msgid "Banners Billing Support Label"
msgstr "Liên hệ bộ phận hỗ trợ"
37 changes: 37 additions & 0 deletions cozy.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,22 @@ contexts:
# Feature flags
features:
- hide_konnector_errors
# Materialize the platform banners (io.cozy.banners) for the instances of
# this context. Off by default, so the rules can ship before the clients
# that render them. Turning it back off stops the writes and leaves the
# documents already materialized in place.
enable_banners: true

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

group to the banner: and one struct

# The banner categories the stack.banner.commands queue is allowed to
# write. Its publisher is authenticated by its broker credentials and
# bindings, so this says what it may say, not who it is. The quota
# category is always refused: the stack measures disk usage itself.
# See docs/banners.md.
banner_command_categories:
- billing
- trial
# The hosts a banner command's call to action may link to.
banner_cta_hosts:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

how I can enable everything?

- manager.example.org
# List of applications that can be automatically updated even if the
# permissions have changed
additional_platform_apps:
Expand Down Expand Up @@ -746,3 +762,24 @@ rabbitmq:
delivery_limit: 5
bindings:
- app.installation.requested
# Banner commands. The exchange is a deployment choice: an existing one
# with dedicated bindings works too, as long as both repositories name the
# same one. The dead letter queue is where a malformed or unauthorized
# command lands, so it has to exist for those to be inspectable.
- name: platform
kind: topic
durable: true
declare_exchange: false
queues:
- name: stack.banner.commands
declare: true
declare_dlx: true
declare_dlq: true
dlx_name: stack.platform.dlx
dlq_name: stack.dead.letter.banner.commands
dl_routing_key: banner.commands.dead
prefetch: 8
delivery_limit: 5
bindings:
- banner.materialize
- banner.clear
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ Feel free to [open an issue](https://github.com/cozy/cozy-stack/issues/new) for

### Up-to-date

- [Banners](banners.md)
- [Flagship app](flagship.md)
- [Move design](move-design.md)
- [Realtime internals](realtime-internals.md)
Expand Down
141 changes: 141 additions & 0 deletions docs/banners.md

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

too much text here, and it feels we are just repeating what the ADR said

@MontaGhanmy MontaGhanmy Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Trimmed in 49db1bf

Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
## Banners

This is the publisher reference for backend banner commands. The stack stores
one `io.cozy.banners` document per category and instance. See
[ADR 054](https://github.com/linagora/twake-workplace-private/blob/main/documentation/docs/adrs/adr-054.md)
for the platform design.

### Configuration

Enable banners and allow the publisher's categories in each recipient context:

```yaml
contexts:
b2b_twake_default:
enable_banners: true
banner_command_categories:
- billing
- trial
banner_cta_hosts:
- manager.example.org
```

Broker credentials, permissions and bindings control who can publish. Each
category must have one owner and one addressing mode: the stack keeps one
revision per instance and category, shared by `tenant` and `workplaceFqdn`
commands, so never address a category both ways. `quota` is reserved for the
stack's rules.

Instances that disable banners are skipped. Instances whose context does not
list the category or a CTA host are skipped with a warning log. Other eligible
recipients still receive the command. Skipping an instance leaves its existing
documents and recorded revision unchanged. An error on one instance
does not stop processing the others. The stack returns all failures after
attempting every recipient, so delivery can be retried.

### Commands

Publish JSON on the `platform` exchange, consumed by `stack.banner.commands`.
The routing key selects the operation:

- `banner.materialize`: create or replace the banner in a category.
- `banner.clear`: expire the banner in a category while retaining its revision; nonempty presentation fields
are rejected.

See [RabbitMQ configuration](rabbitmq.md#configuration) for queue declarations
and [shared fixtures](../model/banner/testdata) for complete examples.

`banner.materialize`:

```json
{
"workplaceFqdn": "alice.twake.app",
"eventId": "banner-command-42",
"revision": 42,
"timestamp": 1788944400,
"category": "billing",
"bannerId": "billing.grace.cycle-a.attempt-2",
"severity": "warning",
"surface": "banner",
"dismissible": true,
"text": { "en": "We could not charge your card.", "fr": "Nous n'avons pas pu débiter votre carte." },
"cta": {
"label": { "en": "Update payment method", "fr": "Mettre à jour le moyen de paiement" },
"url": "https://manager.example.org/linagora/twake_prod/premium"
}
}
```

`banner.clear`:

```json
{
"workplaceFqdn": "alice.twake.app",
"eventId": "banner-command-43",
"revision": 43,
"timestamp": 1788944400,
"category": "billing"
}
```

| Field | Required | Contract |
| --- | --- | --- |
| `category` | always | Matches `^[a-z][a-z0-9-]{0,31}$`; `quota` is rejected. |
| `workplaceFqdn` / `tenant` | exactly one | A single instance host name / a B2B organization ID matching instance `org_id`, whose members receive the command; `tenant` is at most 256 bytes with no surrounding whitespace. |
| `revision` | always | Positive counter, increasing per category. |
| `timestamp` | always | Decision time in positive epoch seconds, within the RFC3339 range. Does not order commands. |
| `eventId` | no | Correlation ID, at most 256 bytes. |
| `bannerId` | materialize | Matches `^[a-z0-9.-]{1,64}$`. Keep it for the same occurrence to preserve dismissal; change it for a new occurrence. |
| `severity` | materialize | `info`, `warning` or `error`. |
| `surface` | materialize | `banner` or `modal`. |
| `text` | materialize | Locale map with nonempty `en`; at most 1024 bytes per locale. |
| `title` | no | Locale map with nonempty `en` when supplied; at most 256 bytes per locale. |
| `cta`, `secondaryCta` | no | Each has a locale-map `label` (nonempty `en`, at most 128 bytes per locale) and an absolute `https` `url` (at most 2048 bytes) whose host is in `banner_cta_hosts`. A secondary CTA requires a primary one. |
| `dismissible` | no | Defaults to false. A modal without a CTA is made dismissible. |
| `priority` | no | 0–1000; defaults to 0. Quota banners use 50 and 100. |
| `startsAt`, `endsAt` | no | RFC3339. If both are supplied, `startsAt` must precede `endsAt`. An explicit start replaces the stored start; omission preserves it for the same occurrence when compatible with the end, otherwise defaults to the command's decision time. |

Each locale map accepts at most 32 locales with keys of 1–35 bytes. The JSON
body is limited to 256 KiB, including whitespace and unknown fields.
`_id`, `_rev`, `dismissedAt` and `cozyMetadata` are not command fields and are
ignored if supplied.

### Localization

The publisher supplies all wording. The stack selects the instance's locale
only if it is complete for every supplied text and label; otherwise the whole
banner falls back to `en`. The stored `lang` identifies the selected language.
Any complete publisher-supplied locale is supported, independently of the
stack's translation catalogs.

On an instance language change, existing banners are re-localized from retained
commands in the banner documents without republishing. Cleared or deleted
banners and older records without retained wording are left unchanged.

### Revisions and recovery

Commanded banners store `revision`, `eventId`, and the full localized command
in `accepted` alongside their presentation. A clear retains the category's
document with `cleared: true`, an expired `endsAt`, and no retained wording;
clients must filter out banners whose validity window has ended. A newer
materialize replaces it normally. Updating the command revision also updates
the document revision, even when its visible wording is unchanged.

These fields use the same app permissions as the banner. Apps recording a
dismissal should preserve the other fields and use the current CouchDB `_rev`;
editing or deleting the ordering state can allow stale commands to be replayed.

- Revisions at or below the last accepted revision for an instance and category
are ignored, even after a clear. Only a changed decision needs a new revision;
the publisher must ensure newer revisions carry newer state.
- Retry with the original revision, event ID and payload. Replays complete
partial organization deliveries and reach newly provisioned members while
leaving recipients that already accepted the revision unchanged.
- Enabling banners does not bootstrap them: the publisher must republish.
- Invalid commands and missing workplaces fail delivery. The broker requeues
failures without a delay until its configured delivery limit is exhausted;
configure dead lettering as described in [RabbitMQ](rabbitmq.md#dead-letter-exchange-dlx-and-dead-letter-queue-dlq).
Fix the cause and explicitly replay dead-lettered commands with their original
operation routing key (`banner.materialize` or `banner.clear`).
- The stack sends no application acknowledgement. A broker confirm means the
broker accepted the message, not that a banner was stored or displayed.
24 changes: 24 additions & 0 deletions docs/rabbitmq.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,23 @@ rabbitmq:
delivery_limit: 5
bindings:
- app.installation.requested
- name: platform
kind: topic
durable: true
declare_exchange: false
queues:
- name: stack.banner.commands
declare: true
declare_dlx: true
declare_dlq: true
dlx_name: stack.platform.dlx
dlq_name: stack.dead.letter.banner.commands
dl_routing_key: banner.commands.dead
prefetch: 8
delivery_limit: 5
bindings:
- banner.materialize
- banner.clear
```

### Dead Letter Exchange (DLX) and Dead Letter Queue (DLQ)
Expand Down Expand Up @@ -267,12 +284,19 @@ type Handler interface {

Returning `nil` acknowledges the message. Returning a non-nil error causes the message to be requeued (subject to broker policies and delivery limits).

A handler does not classify its errors. Every failure is nacked with requeue,
and the queue's `delivery_limit` is what bounds the retries: once it is reached
the broker dead letters the message. So a payload that does not parse costs a
few redeliveries before it lands in the dead letter queue, and a storage failure
gets those same attempts to succeed.

Queue names are mapped to handlers in the stack. For example:

- `user.password.updated` → updates an instance passphrase when a `user.password.updated` routing key is received.
- `user.created` → validates and processes user creation events.
- `user.phone.updated` → updates the phone number stored in user settings.
- `domain.user.deleted` on the `b2b` exchange → removes externally managed organization contacts.
- `banner.materialize` and `banner.clear` on the `platform` exchange → materializes or clears a platform banner, see [Banners](banners.md).

Message schemas are JSON and validated in the handler. Example payload for `user.password.updated`:

Expand Down
32 changes: 32 additions & 0 deletions model/banner/banner.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
package banner

import (
"maps"
"time"

"github.com/cozy/cozy-stack/pkg/consts"
Expand Down Expand Up @@ -78,6 +79,13 @@ type Banner struct {
EndsAt *time.Time `json:"endsAt,omitempty"`
Source Source `json:"source"`

// Command state shares the document's ordinary app permissions. A clear
// expires the banner instead of deleting its ordering history.
Revision int64 `json:"revision,omitempty"`
EventID string `json:"eventId,omitempty"`
Cleared bool `json:"cleared,omitempty"`
Accepted *Command `json:"accepted,omitempty"`

Metadata *metadata.CozyMetadata `json:"cozyMetadata,omitempty"`
}

Expand Down Expand Up @@ -111,6 +119,30 @@ func (b *Banner) clone() *Banner {
at := *b.EndsAt
cloned.EndsAt = &at
}
if b.Accepted != nil {
cmd := *b.Accepted
cmd.Title = maps.Clone(cmd.Title)
cmd.Text = maps.Clone(cmd.Text)
if cmd.CTA != nil {
cta := *cmd.CTA
cta.Label = maps.Clone(cta.Label)
cmd.CTA = &cta
}
if cmd.SecondaryCTA != nil {
cta := *cmd.SecondaryCTA
cta.Label = maps.Clone(cta.Label)
cmd.SecondaryCTA = &cta
}
if cmd.StartsAt != nil {
at := *cmd.StartsAt
cmd.StartsAt = &at
}
if cmd.EndsAt != nil {
at := *cmd.EndsAt
cmd.EndsAt = &at
}
cloned.Accepted = &cmd
}
if b.Metadata != nil {
cloned.Metadata = b.Metadata.Clone()
}
Expand Down
Loading
Loading