Stage 7 · Master
Phase 11 — Notice Service
Notice Board
Build a tenant-safe notice publication pipeline that targets the right residents and lets PostgreSQL decide when scheduled posts go live.
Which rows define a notice's lifecycle?
A society notice is not just a blob of text with a publish button; it is a state machine that starts as a draft, becomes scheduled when a future publish time is chosen, flips to published when that moment is actually due, and eventually moves to archived when it should disappear from the active feed. Keeping those states explicit prevents a common failure mode: a handler that treats any row with a non-null scheduled_at as public, even if the publisher has not promoted it yet.
By module 11 the platform already trusts org_id from the JWT claims that the API gateway validated, so every notice row and every audience row must carry org_id and every repository method must scope by it. That is the line between 'Block C water shutdown' reaching the right residents and a multi-tenant leak that later notification digests would amplify.
If one replica's application clock is 90 seconds fast, comparing scheduled_at to time.Now() can publish a notice early on that pod and late on another one. The worker should fetch current time from PostgreSQL and let the same authority that stores scheduled_at decide when 'due' begins.
How do notices encode tenant-safe audience scopes?
A notice either targets everyone in one organization or a custom audience defined by rows in notice_audience. The table needs enough structure to say 'all residents in Block C' without forcing the product to create hundreds of per-flat rows, but it also must support exact flat targeting for cases like lift maintenance in only one tower's penthouse stack.
| Audience shape | Why it exists | Failure mode if modeled poorly |
|---|---|---|
| audience_scope = all | Town-hall announcements, AGM reminders, broad policy updates | If you still require join-table rows, editors will create redundant per-block entries and the 'everyone' path becomes slower than necessary |
| notice_audience rows by block | Localized alerts such as block-level plumbing or painting work | If block rows do not carry org_id, a join on block_code alone can pull recipients from another tenant that also has a Block C |
| notice_audience rows by flat | Pinpoint communication like balcony inspection access for specific units | If the repository trusts a flat_id without org scoping, an editor could accidentally or maliciously reference another organization's flat UUID |
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE TABLE notices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
title TEXT NOT NULL,
body TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('draft', 'scheduled', 'published', 'archived')),
audience_scope TEXT NOT NULL CHECK (audience_scope IN ('all', 'custom')),
created_by UUID NOT NULL,
scheduled_at TIMESTAMPTZ,
published_at TIMESTAMPTZ,
archived_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT notices_id_org_unique UNIQUE (id, org_id),
CONSTRAINT notices_schedule_state_chk CHECK (
(state = 'draft' AND scheduled_at IS NULL AND published_at IS NULL AND archived_at IS NULL)
OR (state = 'scheduled' AND scheduled_at IS NOT NULL AND published_at IS NULL AND archived_at IS NULL)
OR (state = 'published' AND published_at IS NOT NULL AND archived_at IS NULL)
OR (state = 'archived' AND published_at IS NOT NULL AND archived_at IS NOT NULL)
)
);
CREATE INDEX notices_org_state_schedule_idx
ON notices (org_id, state, scheduled_at, id);
CREATE INDEX notices_org_published_feed_idx
ON notices (org_id, published_at DESC, id DESC)
WHERE state = 'published';
CREATE TABLE notice_audience (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
notice_id UUID NOT NULL,
scope_type TEXT NOT NULL CHECK (scope_type IN ('block', 'flat')),
block_code TEXT,
flat_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
FOREIGN KEY (notice_id, org_id) REFERENCES notices(id, org_id) ON DELETE CASCADE,
CONSTRAINT notice_audience_scope_chk CHECK (
(scope_type = 'block' AND block_code IS NOT NULL AND flat_id IS NULL)
OR (scope_type = 'flat' AND flat_id IS NOT NULL AND block_code IS NULL)
)
);
CREATE UNIQUE INDEX notice_audience_uniqueness_idx
ON notice_audience (
notice_id,
scope_type,
COALESCE(block_code, ''),
COALESCE(flat_id::text, '')
);
CREATE INDEX notice_audience_org_notice_idx
ON notice_audience (org_id, notice_id);
CREATE INDEX notice_audience_org_block_idx
ON notice_audience (org_id, block_code)
WHERE scope_type = 'block';
CREATE INDEX notice_audience_org_flat_idx
ON notice_audience (org_id, flat_id)
WHERE scope_type = 'flat';The composite foreign key (notice_id, org_id) forces the audience row to belong to the same tenant as the notice even before repository code runs.
This migration shows both kinds side by side. notice_id points at a table notice-service owns, so the composite key is real and the database rejects a mismatched tenant outright. flat_id and block_code identify audience targets that flat-service owns, and no constraint here can vouch for them — an audience row can name a flat that was deleted this morning. That asymmetry decides where each rule is enforced: tenant consistency is a schema guarantee, audience validity is a resolution-time check against the owning service, and an unresolvable target is reported as a delivery gap rather than silently dropped.
DROP TABLE IF EXISTS notice_audience;
DROP TABLE IF EXISTS notices;How does the service resolve who should actually see a notice?
The write path should never accept org_id from JSON. It comes from request context, the same way earlier modules scoped maintenance and complaints. The service turns input into either an all-residents notice with no audience rows or a custom notice with validated block and flat selectors, then exposes a preview query so an editor can see who would receive the post before publishing it.
Cross-org audience rows must not survive preview resolution
A naive preview query that joins residents to notice_audience on block_code or flat_id alone will accidentally include tenants from another organization when naming conventions overlap. The preview path is exactly where you catch that bug early, because the recipient list is small and human-visible before downstream email or push delivery exists.
package notice
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
)
type State string
type AudienceScope string
type AudienceType string
const (
StateDraft State = "draft"
StateScheduled State = "scheduled"
StatePublished State = "published"
StateArchived State = "archived"
AudienceScopeAll AudienceScope = "all"
AudienceScopeCustom AudienceScope = "custom"
AudienceTypeBlock AudienceType = "block"
AudienceTypeFlat AudienceType = "flat"
)
var (
ErrInvalidSchedule = errors.New("notice: scheduled notices require a future scheduled_at")
ErrInvalidAudience = errors.New("notice: invalid audience configuration")
ErrNoticeNotFound = errors.New("notice: not found")
ErrCrossTenantRecipient = errors.New("notice: recipient resolution crossed tenant boundary")
)
type Notice struct {
ID uuid.UUID
OrgID uuid.UUID
Title string
Body string
State State
AudienceScope AudienceScope
CreatedBy uuid.UUID
ScheduledAt *time.Time
PublishedAt *time.Time
ArchivedAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
type AudienceRule struct {
ScopeType AudienceType
BlockCode string
FlatID *uuid.UUID
}
type CreateNoticeInput struct {
Title string
Body string
PublishNow bool
ScheduledAt *time.Time
AudienceScope AudienceScope
Audience []AudienceRule
}
type Repository interface {
Create(ctx context.Context, notice Notice, audience []AudienceRule) (Notice, error)
ListVisibleResidentUserIDs(ctx context.Context, orgID, noticeID uuid.UUID) ([]uuid.UUID, error)
UpdateState(ctx context.Context, orgID, noticeID uuid.UUID, from State, to State, publishedAt *time.Time) (Notice, error)
}
type Service struct {
repo Repository
}
func NewService(repo Repository) *Service {
return &Service{repo: repo}
}
func (s *Service) Create(ctx context.Context, orgID, actorID uuid.UUID, input CreateNoticeInput) (Notice, error) {
title := strings.TrimSpace(input.Title)
body := strings.TrimSpace(input.Body)
if title == "" || body == "" {
return Notice{}, fmt.Errorf("%w: title and body are required", ErrInvalidAudience)
}
state := StateDraft
var publishedAt *time.Time
if input.PublishNow {
now := time.Now().UTC()
state = StatePublished
publishedAt = &now
input.ScheduledAt = nil
}
if input.ScheduledAt != nil {
if !input.ScheduledAt.After(time.Now().UTC()) {
return Notice{}, ErrInvalidSchedule
}
state = StateScheduled
}
if err := validateAudience(input.AudienceScope, input.Audience); err != nil {
return Notice{}, err
}
notice := Notice{
OrgID: orgID,
Title: title,
Body: body,
State: state,
AudienceScope: input.AudienceScope,
CreatedBy: actorID,
ScheduledAt: input.ScheduledAt,
PublishedAt: publishedAt,
}
return s.repo.Create(ctx, notice, input.Audience)
}
func (s *Service) PreviewRecipients(ctx context.Context, orgID, noticeID uuid.UUID) ([]uuid.UUID, error) {
recipients, err := s.repo.ListVisibleResidentUserIDs(ctx, orgID, noticeID)
if err != nil {
return nil, err
}
for _, userID := range recipients {
if userID == uuid.Nil {
return nil, ErrCrossTenantRecipient
}
}
return recipients, nil
}
func (s *Service) Archive(ctx context.Context, orgID, noticeID uuid.UUID) (Notice, error) {
publishedAt := time.Now().UTC()
return s.repo.UpdateState(ctx, orgID, noticeID, StatePublished, StateArchived, &publishedAt)
}
func validateAudience(scope AudienceScope, audience []AudienceRule) error {
switch scope {
case AudienceScopeAll:
if len(audience) != 0 {
return fmt.Errorf("%w: all-scope notices must not persist audience rows", ErrInvalidAudience)
}
return nil
case AudienceScopeCustom:
if len(audience) == 0 {
return fmt.Errorf("%w: custom-scope notices require at least one audience row", ErrInvalidAudience)
}
for _, rule := range audience {
switch rule.ScopeType {
case AudienceTypeBlock:
if strings.TrimSpace(rule.BlockCode) == "" || rule.FlatID != nil {
return fmt.Errorf("%w: block audience requires block_code only", ErrInvalidAudience)
}
case AudienceTypeFlat:
if rule.FlatID == nil || strings.TrimSpace(rule.BlockCode) != "" {
return fmt.Errorf("%w: flat audience requires flat_id only", ErrInvalidAudience)
}
default:
return fmt.Errorf("%w: unsupported audience type %q", ErrInvalidAudience, rule.ScopeType)
}
}
return nil
default:
return fmt.Errorf("%w: unsupported audience scope %q", ErrInvalidAudience, scope)
}
}Service methods accept orgID as an explicit argument from request context and never from the payload, which keeps tenant identity outside the caller's control.
Why must the publisher ask PostgreSQL for current time?
The publisher's hot path is simple: get one authoritative timestamp from PostgreSQL, list organizations that have due scheduled notices, and publish due rows inside org-scoped batches. Batching by org_id matters even before notifications exist, because the next module will almost certainly fan out published notices into digest or push jobs. Mixing tenants in one batch makes that downstream work harder to keep isolated and easier to leak.
Clock skew is not theoretical in Kubernetes. One pod can run slightly ahead, another behind, and horizontal scale turns a tiny time drift into duplicate or premature publication. Pulling current time from PostgreSQL means every replica compares against the same clock source.
package notice
import (
"context"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/rs/zerolog"
)
type PublisherStore interface {
TryAcquireLeader(ctx context.Context) (bool, error)
OrganizationsWithDueNotices(ctx context.Context, asOf time.Time, limit int) ([]uuid.UUID, error)
PublishDueNotices(ctx context.Context, orgID uuid.UUID, asOf time.Time, limit int) ([]Notice, error)
}
type TimeSource interface {
Now(ctx context.Context) (time.Time, error)
}
type DBTimeSource struct {
pool *pgxpool.Pool
}
func NewDBTimeSource(pool *pgxpool.Pool) *DBTimeSource {
return &DBTimeSource{pool: pool}
}
func (s *DBTimeSource) Now(ctx context.Context) (time.Time, error) {
var current time.Time
err := s.pool.QueryRow(ctx, "SELECT now()").Scan(¤t)
return current.UTC(), err
}
type Publisher struct {
store PublisherStore
timeSource TimeSource
logger zerolog.Logger
batchSize int
orgLimit int
}
func NewPublisher(store PublisherStore, timeSource TimeSource, logger zerolog.Logger) *Publisher {
return &Publisher{
store: store,
timeSource: timeSource,
logger: logger,
batchSize: 100,
orgLimit: 25,
}
}
func (p *Publisher) RunOnce(ctx context.Context) error {
leader, err := p.store.TryAcquireLeader(ctx)
if err != nil {
return err
}
if !leader {
p.logger.Info().Ctx(ctx).Msg("notice publisher skipped because another worker owns the lock")
return nil
}
asOf, err := p.timeSource.Now(ctx)
if err != nil {
return err
}
orgIDs, err := p.store.OrganizationsWithDueNotices(ctx, asOf, p.orgLimit)
if err != nil {
return err
}
for _, orgID := range orgIDs {
published, err := p.store.PublishDueNotices(ctx, orgID, asOf, p.batchSize)
if err != nil {
return err
}
for _, notice := range published {
p.logger.Info().Ctx(ctx).
Str("org_id", notice.OrgID.String()).
Str("notice_id", notice.ID.String()).
Interface("scheduled_at", notice.ScheduledAt).
Interface("published_at", notice.PublishedAt).
Msg("notice published")
}
}
return nil
}
func PublishDueSQL() string {
return "WITH due AS (" +
" SELECT id" +
" FROM notices" +
" WHERE org_id = $1 AND state = 'scheduled' AND scheduled_at <= $2" +
" ORDER BY scheduled_at ASC, id ASC" +
" LIMIT $3" +
" FOR UPDATE SKIP LOCKED" +
")" +
" UPDATE notices AS n" +
" SET state = 'published', published_at = $2, updated_at = $2" +
" FROM due" +
" WHERE n.id = due.id AND n.org_id = $1" +
" RETURNING n.id, n.org_id, n.title, n.body, n.state, n.audience_scope, n.created_by, n.scheduled_at, n.published_at, n.archived_at, n.created_at, n.updated_at"
}The same advisory-lock idea used in Complaint's SLA worker keeps this publisher safe under replica count changes; the difference here is the worker batches by org_id before flipping states.
How do you verify publication and recipient resolution locally?
A good local loop proves three separate things: the migration created the lifecycle tables correctly, the publisher only promotes due rows, and recipient preview respects tenant and audience boundaries. One useful negative test is to seed two organizations that both have a Block C and confirm only the requesting org's residents appear in the preview.
Applied exercise
Trace recipients for a scheduled plumbing notice
Org Alpha schedules a notice for tomorrow at 09:00 titled 'Block C water shutdown' and stores two audience rows: one block row for Alpha's Block C and one accidental flat row pointing at a flat UUID that belongs to Org Beta. You need to predict what should happen before the notice is published and which residents should later receive it.
- List the notice state transitions from draft creation to the first publisher run after 09:00.
- Determine which audience row is valid and which one must be ignored or rejected because it crosses tenants.
- Explain which residents in Org Alpha should see the notice and why Block A residents in the same org should not.
- Describe the SQL predicate that prevents Org Beta residents from leaking into the preview or future notification fan-out.
Deliverable
A written recipient matrix showing included residents, excluded residents, the rejected cross-org row, and the exact scheduled_at boundary that turns the notice public.
Completion checks
- The answer distinguishes all-residents scope from custom audience scope instead of mixing them.
- The cross-org audience row is called out as a tenant-isolation defect, not as a harmless bad input.
- The publication decision is based on database time reaching scheduled_at, not on an editor's browser clock.
Why should the publisher compare scheduled_at against PostgreSQL's current time instead of time.Now() from the worker process?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.