Bridge Training

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

ExportDefinition
ANNOUNCEMENT_KINDS["banner", "news"]
NEWS_FEED_LIMIT10: how many news items the popover lists; older ones fall off the bottom.

Rules

FunctionSignatureDescription
isAnnouncementVisible(announcement: Pick<Announcement, "pinned" | "publishedAt" | "expiresAt">, context: AnnouncementVisibilityContext) => booleanThe 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[]) => AnnouncementFeedSorts pinned first then newest first. banner is the latest unread banner or null; news keeps the news items, capped at NEWS_FEED_LIMIT.
announcements.ts
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 exclusive
announcements.ts
export 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

MethodSignatureDescription
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.
announcements.ts
// 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.

MethodSignatureDescription
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:

FieldRuleReason on failure
kindOne of ANNOUNCEMENT_KINDS.invalid_kind
title, bodyEvery language trimmed, empty ones dropped; at least one language left.must_be_object, invalid_string, empty
publishedAtParseable instant, normalized to ISO.invalid_date
expiresAtOptional instant, must be after publishedAt; required for a banner.invalid_date, must_be_after_published_at, required_for_banner
actionPathTrimmed; blank becomes null; must start with /.invalid_string, must_be_internal_path
actionLabelRequired (non-empty labels) when actionPath is set, null otherwise.empty
pinnedtrue only when strictly true.
announcements.ts
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.

ExportBindsNotes
AnnouncementWhereWhereShape{ id?, visibleAt? }. visibleAt takes an AnnouncementVisibilityContext and is isAnnouncementVisible as a filter.
AnnouncementQueryQueryObject<AnnouncementQueryParams>empty(), feedOf(accountId, context), withId, withIds, visibleAt, asReader(accountId), withPagination, withFilters. No sort option: newest publication first.
AnnouncementCriteriaCriteria<AnnouncementWhere>byId, byIds, where, instance and.
AnnouncementProjectionsProjection<T>entity() is the Announcement; withReadMark() is the AnnouncementForAccount of the query's reader, and requires asReader.
AnnouncementAggregationsAggregation<T>count(criteria?).
AnnouncementPatchTPatchPartial<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.

MethodSignatureDescription
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

ErrorCodeThrown by
ValidationErrorVALIDATION_ERRORnormalizeAnnouncement, hence AnnouncementsAdminService.create and update. Context { field, reason } as listed above.

See Errors for the base classes.