Announcements
In-app messages published to every account, the visibility rule, the per-account feed with read marks, and the admin catalogue.
The announcements module models in-app messages published to every account. A banner is a one-line strip above the page, a news item is listed in the bell popover. Copy is localized in the row itself so a message ships without a release. The teacher app reads the feed and marks announcements read, the admin app manages the catalogue.
Import from @workspace/training/domain/announcements and @workspace/training/application/announcements.
Domain
Announcement
Prop
Type
AnnouncementForAccount is an announcement as one account sees it: Announcement & { readAt: string | null }. NewAnnouncement is an Announcement without id, createdAt and updatedAt.
Prop
Type
Prop
Type
| Export | Definition |
|---|---|
ANNOUNCEMENT_KINDS | ["banner", "news"] |
NEWS_FEED_LIMIT | 10: how many news items the popover lists; older ones fall off the bottom. |
Rules
| Function | Signature | Description |
|---|---|---|
isAnnouncementVisible | (announcement: Pick<Announcement, "pinned" | "publishedAt" | "expiresAt">, context: AnnouncementVisibilityContext) => boolean | The single visibility rule, shared by every store: published, not expired (expiresAt is exclusive), and either pinned or published after the account was created. An unknown account age falls back to the publication rules alone. |
buildAnnouncementFeed | (visible: readonly AnnouncementForAccount[]) => AnnouncementFeed | Sorts pinned first then newest first. banner is the latest unread banner or null; news keeps the news items, capped at NEWS_FEED_LIMIT. |
const context = {
accountCreatedAt: "2026-09-01T00:00:00.000Z",
now: "2026-10-08T12:00:00.000Z",
};
isAnnouncementVisible(
{ pinned: false, publishedAt: "2026-10-01T00:00:00.000Z", expiresAt: null },
context
); // => true: published after the account was created
isAnnouncementVisible(
{ pinned: false, publishedAt: "2026-08-01T00:00:00.000Z", expiresAt: null },
context
); // => false: older than the account
isAnnouncementVisible(
{ pinned: true, publishedAt: "2026-08-01T00:00:00.000Z", expiresAt: null },
context
); // => true: pinned announcements ignore the account age
isAnnouncementVisible(
{ pinned: true, publishedAt: "2026-08-01T00:00:00.000Z", expiresAt: "2026-10-08T12:00:00.000Z" },
context
); // => false: expiry is exclusiveexport function feedOf(faker: Faker) {
const banner = makeAnnouncementForAccount(faker, { kind: "banner" });
const readBanner = makeAnnouncementForAccount(faker, {
kind: "banner",
readAt: faker.date.recent().toISOString(),
});
const pinnedNews = makeAnnouncementForAccount(faker, { pinned: true });
const feed = buildAnnouncementFeed([readBanner, banner, pinnedNews]);
feed.banner; // => banner (the unread one)
feed.news; // => [pinnedNews]: pinned first, newest first, at most NEWS_FEED_LIMIT
return feed;
}Application
AnnouncementsService
createAnnouncementsService(deps: AnnouncementsServiceDeps): AnnouncementsService
Feed of one account and its read marks. The service is built for one account: it reads that account's creation date through AccountsService.getById, only on the calls that list, adds the clock, and reads the feed as AnnouncementQuery.feedOf(accountId, context) with the withReadMark() projection. Reading a news item and closing a banner are the same fact, so both go through markRead, a writeMany on the announcement_reads junction.
Prop
Type
| Method | Signature | Description |
|---|---|---|
getFeed | () => Promise<AnnouncementFeed> | buildAnnouncementFeed over the announcements visible to the account at now. |
markRead | (announcementId: string) => Promise<void> | Idempotent: an already-read announcement keeps its first readAt. |
markAllRead | () => Promise<void> | Marks every visible unread announcement of both surfaces. No write when nothing is unread. |
// The service is built for one account; it reads that account's age and adds the clock.
export async function readAnnouncements(
announcementsRead: AnnouncementsReadRepository,
announcementReads: AnnouncementReadsRepository,
accounts: Pick<AccountsService, "getById">,
accountId: string
) {
const announcements = createAnnouncementsService({
announcementsRead,
announcementReads,
accounts,
accountId,
});
const feed = await announcements.getFeed();
if (feed.banner) {
await announcements.markRead(feed.banner.id); // closing a banner is reading it
}
await announcements.markAllRead(); // no write when nothing is unread
return feed;
}AnnouncementsAdminService
createAnnouncementsAdminService(deps: AnnouncementsAdminServiceDeps): AnnouncementsAdminService, with deps { announcements, announcementsRead }.
Validates an announcement before it reaches the catalogue. Every failure is a ValidationError carrying field and reason, which the admin form maps to a message next to the field.
| Method | Signature | Description |
|---|---|---|
listAll | () => Promise<Announcement[]> | |
getById | (id: string) => Promise<Announcement | null> | |
create | (input: NewAnnouncement) => Promise<Announcement> | Runs normalizeAnnouncement first. |
update | (id: string, input: NewAnnouncement) => Promise<Announcement | null> | Full replacement after normalizeAnnouncement; null when the row is gone. |
delete | (id: string) => Promise<void> |
normalizeAnnouncement(input: NewAnnouncement): NewAnnouncement is exported on its own. Its rules:
| Field | Rule | Reason on failure |
|---|---|---|
kind | One of ANNOUNCEMENT_KINDS. | invalid_kind |
title, body | Every language trimmed, empty ones dropped; at least one language left. | must_be_object, invalid_string, empty |
publishedAt | Parseable instant, normalized to ISO. | invalid_date |
expiresAt | Optional instant, must be after publishedAt; required for a banner. | invalid_date, must_be_after_published_at, required_for_banner |
actionPath | Trimmed; blank becomes null; must start with /. | invalid_string, must_be_internal_path |
actionLabel | Required (non-empty labels) when actionPath is set, null otherwise. | empty |
pinned | true only when strictly true. |
const draft: NewAnnouncement = {
kind: "banner",
title: { fr: " Nouveautés ", en: "What's new" },
body: { fr: "Le lecteur gagne un mode nuit.", en: "" }, // empty languages are dropped
actionLabel: { fr: "Voir", en: "See" },
actionPath: "/settings/player",
pinned: false,
publishedAt: "2026-10-08T08:00:00Z",
expiresAt: "2026-10-15T08:00:00Z", // required for a banner
};
normalizeAnnouncement(draft).title; // => { fr: "Nouveautés", en: "What's new" }
export async function publish(deps: AnnouncementsAdminServiceDeps) {
const admin = createAnnouncementsAdminService(deps);
try {
return await admin.create({ ...draft, actionPath: "https://example.org" });
} catch (error) {
if (error instanceof ValidationError) {
error.code; // => "VALIDATION_ERROR"
error.context; // => { field: "actionPath", reason: "must_be_internal_path" }
}
throw error;
}
}Query objects
The catalogue binds the canonical surface of Queries.
| Export | Binds | Notes |
|---|---|---|
AnnouncementWhere | WhereShape | { id?, visibleAt? }. visibleAt takes an AnnouncementVisibilityContext and is isAnnouncementVisible as a filter. |
AnnouncementQuery | QueryObject<AnnouncementQueryParams> | empty(), feedOf(accountId, context), withId, withIds, visibleAt, asReader(accountId), withPagination, withFilters. No sort option: newest publication first. |
AnnouncementCriteria | Criteria<AnnouncementWhere> | byId, byIds, where, instance and. |
AnnouncementProjections | Projection<T> | entity() is the Announcement; withReadMark() is the AnnouncementForAccount of the query's reader, and requires asReader. |
AnnouncementAggregations | Aggregation<T> | count(criteria?). |
AnnouncementPatch | TPatch | Partial<NewAnnouncement>; null clears a nullable field. |
Ports
AnnouncementsReadRepository is ReadRepository<AnnouncementQuery>. AnnouncementsRepository is WriteRepository<AnnouncementCriteria, AnnouncementPatch, PersistableAnnouncementWrite> plus writeOne(input) => Promise<{ announcementId }>; PersistableAnnouncementWrite is NewAnnouncement. The pair is built on the owner connection, for the admin and for the feed alike.
AnnouncementReadsRepository is the announcement_reads junction between an account and the announcements it has read. Its marks are read through withReadMark(), so it only writes.
| Method | Signature | Description |
|---|---|---|
writeMany | (links: readonly AnnouncementReadLink[]) => Promise<{ added: number }> | Set union of { accountId, announcementId } marks; an existing mark keeps its first readAt and is not counted. |
Errors
| Error | Code | Thrown by |
|---|---|---|
ValidationError | VALIDATION_ERROR | normalizeAnnouncement, hence AnnouncementsAdminService.create and update. Context { field, reason } as listed above. |
See Errors for the base classes.