contracts

package
v1.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 3, 2026 License: Apache-2.0 Imports: 7 Imported by: 0

Documentation

Overview

Package contracts is everything another module, an app or a test may know about notifications: the entity, the notice, the events, the two interfaces this module needs somebody else to satisfy, and the Service. The implementation is in ../internal.

A notification is a row addressed to one person, and optionally an email. Both come from one call — Notify — because "tell somebody" is one intention, and a caller that had to write the row and then send the mail would be a caller that can do half of it.

Index

Constants

View Source
const (
	EventCreated        = "notification.created"
	EventEmailRequested = "notification.email_requested"
	EventRead           = "notification.read"
)

The three events this module emits. A subscriber names one of these constants rather than a string, so renaming one is a compile error where it is listened for.

EventEmailRequested is the odd one, and it is the point of this module's shape: it is how "and send it by mail" leaves the request. The row and the event commit together in the caller's transaction and the worker renders and sends, so a request never waits on somebody else's machine, a mail server that is down is retried by the outbox, and a message that can never be sent ends in the kernel's dead letters where somebody can read it.

Variables

Events is every event this module emits, for the manifest.

Functions

This section is empty.

Types

type Created

type Created struct {
	NotificationID uuid.UUID `json:"notificationId"`
	Recipient      uuid.UUID `json:"recipientId"`
	Title          string    `json:"title"`
	At             time.Time `json:"at"`
}

Created is the payload of EventCreated: somebody was told something.

type EmailRequested

type EmailRequested struct {
	NotificationID uuid.UUID `json:"notificationId"`
	Recipient      uuid.UUID `json:"recipientId"`
	At             time.Time `json:"at"`
}

EmailRequested is the payload of EventEmailRequested, and it carries two identifiers and nothing else.

It used to carry the whole message — the address, the title, the body, the link — which saved the worker a query and cost something worth more than the query. An outbox row is kept for a week and copied into every subscriber's trail: modules/audit records every event this application publishes, so a payload with a body in it is the body of every notice in a table nobody treats as a mailbox, and a payload with an address in it is a mailing list. A reset link in one would be a live credential in the audit trail.

So the worker reads the row back inside the event's own tenant transaction and resolves the address there. The consequences are named rather than hidden: a notice deleted before the worker reaches it is a skip and a log line, and an address changed in between is the address the mail goes to, which is the newer of the two answers and the one a person expects.

The convention this makes explicit for one event is the one modules/audit relies on for all of them: an event carries identifiers, not content.

type HostLookup

type HostLookup interface {
	PublicHost(ctx context.Context, tx db.Tx[db.Tenant]) (string, error)
}

HostLookup is how this module turns a tenant into the host its people reach the application at, without naming the module that knows.

A mail client has no base to resolve a path against, so a notice's link has to become a URL somewhere — and the URL has to be the recipient's own host. Every tenant is reached at its own name, so a link built from the application's public host is a link that signs nobody in and, worse, sends one customer's people to another customer's front door.

It takes the worker's own tenant transaction, so the answer is read under the tenant's own policy: the row it looks for is the one row of tenant_hosts that transaction can see. The application satisfies it over the tenant module, the way it satisfies RecipientLookup over the user module.

type Mailer

type Mailer interface {
	Send(ctx context.Context, m Message) error
}

Mailer sends one message. There is one production implementation, SMTP, and one in memory — notification.Mailbox — that a test and an unconfigured deployment both use; a second production sender would be a second thing to keep working for no capability the first does not have.

It is exported rather than internal to this module because it has a second consumer, and the exception is worth naming. Everything this application mails goes out of the worker below, which reads a notification row back and renders it — so whatever is in the message is, by construction, in a row. That is right for every notice there is and wrong for exactly one thing: a set-password link. modules/auth is handed this same Mailer by the composition, mints the token in its own subscription and hands the message over directly, so the secret is in the mail and in no row, no outbox payload and no audit event. The alternative was a live credential sitting in notifications.link, which is what it used to be.

type Message

type Message struct {
	To      string
	Subject string
	Body    string
}

Message is one email, already rendered: the whole of what a Mailer is asked.

type Notice

type Notice struct {
	Recipient uuid.UUID
	Title     string
	Body      string
	Link      string
	Email     bool
}

Notice is what a caller wants somebody told. It is the argument to Notify rather than the entity, because the entity has an id, timestamps and a tenant the server owns, and because Email is a decision about this notice rather than a column of it: a request, not a promise. A recipient with no address gets the row and no mail, and the send happens in the worker.

type Notification

type Notification struct {
	crud.Base

	// RecipientID is who was told. A caller who could re-address a notice
	// could read somebody else's, so nothing writes it but Notify.
	RecipientID uuid.UUID `json:"recipientId" gorm:"type:uuid;not null" format:"uuid" doc:"The person this is for"`
	// Title is the one line a bell shows; Body is the rest.
	Title string `` /* 134-byte string literal not displayed */
	Body  string `json:"body,omitempty" gorm:"type:text;not null;default:''" ui:"widget:textarea" doc:"The rest of the message"`
	// Link is a path within the application and not a URL: an absolute one is
	// a notice somebody can use to send a tenant's users elsewhere.
	Link string `` /* 137-byte string literal not displayed */
	// ReadAt is when the recipient saw it, nil until they have — a timestamp
	// rather than a flag, because "when" is what a support conversation asks.
	ReadAt *time.Time `json:"readAt,omitempty" gorm:"type:timestamptz" doc:"When the recipient read it" readOnly:"true"`
}

Notification is one thing one person was told, in one tenant. crud.Base contributes the id, the timestamps, the soft delete and the tenant column row-level security matches on; RecipientID is a user id with no foreign key behind it, which is what "cross-module dependencies are Go interfaces" costs at the database.

func (Notification) TableName

func (Notification) TableName() string

TableName pins the table, so the entity and migrations/000011 agree.

func (*Notification) Validate

func (n *Notification) Validate(context.Context) error

Validate is the entity's own check, run by kit/crud on every write whichever door it came through.

type Read

type Read struct {
	NotificationID uuid.UUID `json:"notificationId"`
	Recipient      uuid.UUID `json:"recipientId"`
	At             time.Time `json:"at"`
}

Read is the payload of EventRead: the recipient has seen it.

type RecipientLookup

type RecipientLookup interface {
	Email(ctx context.Context, tx db.Tx[db.Tenant], userID uuid.UUID) (string, error)
}

RecipientLookup is how this module turns a user id into an address without naming the user module. The application satisfies it — apps/platformkit adapts user/contracts.Service in four lines — which is what keeps the dependency pointing from the app at both modules rather than from one at the other. It returns crud.ErrNotFound for somebody who is not there and "" for somebody with no address; neither is an error to the caller.

type Service

type Service interface {
	// Notify writes the row, publishes notification.created, and — when the
	// notice asks for mail and the recipient has an address — publishes
	// notification.email_requested. Nothing here talks to a mail server: a
	// request that waited on one would hold a database transaction open across
	// a call to somebody else's machine.
	Notify(ctx context.Context, tx db.Tx[db.Tenant], n Notice) (*Notification, error)

	// MarkRead records that the recipient has seen it and publishes
	// notification.read. Somebody else's notification is not found, which is
	// the check that makes the route safe for any signed-in caller; marking it
	// read again changes nothing and publishes nothing.
	MarkRead(ctx context.Context, tx db.Tx[db.Tenant], id, recipient uuid.UUID) (*Notification, error)

	// ListFor is a page of one person's notifications, newest first. There is
	// no tenant-wide list, which is why this module mounts no rest.Spec.
	ListFor(ctx context.Context, tx db.Tx[db.Tenant], recipient uuid.UUID, q crud.Query) ([]*Notification, int64, error)
}

Service is what a caller does with notifications. Every command takes the caller's transaction rather than opening one, so the row and the events it causes commit together; the errors are kit/crud's.

Directories

Path Synopsis

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL