Help us shape the future of Go! Take the 2026 Go Developer Survey

ticket

package module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Oct 26, 2026 License: MIT Imports: 45 Imported by: 0

README

Arandu

Arandu Ticket

Internal support tickets for a multi-tenant Arandu application: a conversation between a user of a space and the platform team that answers it.

There are two sides, and they are separate. The customer lists their own tickets, opens one, reads the conversation, replies, closes and reopens. The team lists every space's tickets, reads the conversation with its internal notes, answers, writes notes the customer never sees, and changes a ticket's status, priority and assignee. Each side has its own controllers, its own screens and its own prefix, and the application mounts each where its own session is valid: the team's session only on the operator console, the customer's only on the panel.

The module sends no e-mail, knows no table of users and keeps no list of the team. Those are three small interfaces the application implements with what it already has.

Install

go get github.com/hyz-is/arandu-ticket

It requires arandu-activitylog and arandu-markdown, and expects the application to administer permissions with arandu-permission.

Wire it

An Arandu application registers a module explicitly. There is no service provider, no container and no discovery, so these are the lines to paste into bootstrap/app.go and there are no others.

The import, with the other module imports:

import (
	ticket "github.com/hyz-is/arandu-ticket"
)

The construction, in Build, after the session store, the activity log and the markdown module exist and before k.Register:

	ticketModule, err := ticket.New(ticket.Config{
		// The tenant the platform team signs in to: the operator console's
		// realm. The one required setting.
		TeamTenant: cfg.Auth.Tenant,

		// Two sides, two prefixes. The application's own addresses, in the
		// segments of its language.
		Prefix:     "/painel/chamados",
		TeamPrefix: "/admin/chamados",
		Paths:      ticket.PortuguesePaths,

		Accounts:  accountResolver{users: users}, // e-mail -> user and space
		Directory: teamDirectory{users: users},   // names, and the team to assign to
		Notifier:  ticketMail{mailer: mailer},    // the application's own mailer

		Activity: activityModule.Logger(), // arandu-activitylog
		Markdown: markdownModule,          // arandu-markdown
		Files:    filesDisk,               // the private Disk: Cloudflare R2 in production

		// The actions arandu-permission fills are resolved on each request,
		// so its Roles() runs on every route; the console's guard runs on the
		// team's alone.
		Middleware:     []func(http.Handler) http.Handler{permissionModule.Roles()},
		TeamMiddleware: []func(http.Handler) http.Handler{consoleGuard},
	}, db, sessions)
	if err != nil {
		return App{}, err
	}

And the registration, inside the k.Register(...) call already there:

		ticketModule,

An application that keeps the two sides on different routers mounts them itself, each with its own guards, instead of registering the routes through k.Register:

ticketModule.CustomerRoutes(panel)  // under Prefix, with the panel's guards
ticketModule.TeamRoutes(console)    // under TeamPrefix, with the console's

Then, once, before the application serves:

aru migrate

The package owns four tables (tickets, ticket_messages, ticket_attachments, ticket_counters), which is why arandu.mod.toml says migrations = true. arandu-activitylog's migration runs in the same step. Nothing migrates at boot.

Give people access

The policies are in policy.go, and the actions reach them through the subject, which arandu-permission fills. A role opens nothing: a subject that holds no action is refused everything, an administrator included.

Declare the actions in the permission catalogue beside the application's own. The customer's and the team's are two lists, so a catalogue an organization grants from never holds a team action:

permission.Config{
	Actions: append(append(app.Actions(), permission.Actions()...), ticket.Actions()...),
	// ...
}
// and, in the catalogue of the platform's own team only:
//   ticket.TeamActions()
action side what it opens
ticket.create customer opening a ticket in the subject's own space
ticket.view_own customer reading the tickets the subject asked for
ticket.view_space customer reading every ticket of the space, whoever asked; with ticket.reply or ticket.close, answering or closing them too
ticket.reply customer adding a message to a ticket the subject can read
ticket.close customer closing a ticket the subject can read, and reopening it
ticket.view_all team reading every space's tickets, internal notes and files included
ticket.manage team answering a customer, and changing a ticket's status and priority
ticket.assign team giving a ticket to a member of the team
ticket.note team writing a note only the team reads
ticket.open_from_contact none the contact form's act; no person holds it

Each action opens what it names and nothing else, and the Grants for reading (ticket.view_own, ticket.view_space, ticket.view_all) only read, so the database refuses a write made with one.

A member of the team is a subject of Config.TeamTenant who holds the team's actions. TeamPolicy refuses a subject of any other tenant whatever it carries, and a subject somebody else is viewing as.

The three things the application provides

AccountResolver

Resolves an e-mail to the user who owns it and the space they belong to. Only OpenFromContact asks it.

type accountResolver struct{ users *repositories.Users }

func (a accountResolver) Resolve(ctx context.Context, email string) (ticket.Account, error) {
	user, err := a.users.ByEmail(ctx, email)
	if errors.Is(err, model.ErrModelNotFound) {
		return ticket.Account{}, ticket.ErrNoAccount // no account: nothing is opened
	}
	if err != nil {
		return ticket.Account{}, err
	}
	return ticket.Account{UserID: user.ID, Tenant: user.WorkspaceID, Name: user.Name, Email: user.Email}, nil
}

The tenant is the application's answer and nothing a request names. It has to be a valid tenant, or the call fails and nothing is opened.

Notifier

Told after the change is stored, when a ticket is opened (EventOpened), when the customer replies (EventCustomerReplied) and when the team answers (EventTeamReplied). Event.Kind.ForTeam() says who to tell: the team for the first two, the requester of the ticket for the third. An internal note is never an event.

type ticketMail struct{ mailer *mail.Mailer }

func (n ticketMail) Notify(ctx context.Context, e ticket.Event) error {
	to := e.Ticket.RequesterEmail // the requester; the team's address for ForTeam()
	if e.Kind.ForTeam() {
		to = "suporte@example.com"
	}
	return n.mailer.Send(ctx, to, e.Ticket.Code()+" "+e.Ticket.Subject, e.Message.Body)
}

Its failure does not undo the change: the ticket is open and the reply written, and a mail server that is down is no reason for a customer to write it twice. The error is logged (Config.Logger) with the event, the ticket and the error, never the text. An application that must not lose a notification enqueues it here and delivers it from its queue.

Directory (optional)
type Directory interface {
	Person(ctx context.Context, tenant, id string) (Person, error) // ErrNoAccount if none
	Team(ctx context.Context) ([]Person, error)                      // who a ticket can be assigned to
}

Without one, a ticket keeps the requester's name and e-mail it was given (the contact form's account carries them), a message shows its author's role in place of a name, and a ticket can be assigned only to the member of the team who asks.

The contact form

The application's public contact form opens a ticket only for somebody who has an account, and does what it did before for anybody else:

result, err := ticketModule.Tickets().OpenFromContact(ctx, ticket.ContactInput{
	Email: in.Email, Name: in.Name, Subject: in.Subject, Body: in.Message,
})
switch {
case errors.Is(err, ticket.ErrNoAccount):
	// no account: nothing was created; only the e-mail goes out, as before
case err != nil:
	return err
}
// result.Ticket is the ticket; result.Duplicate says the same words had
// already opened it. Send the e-mail as you did.

The same e-mail, subject and body inside Config.ContactWindow (ten minutes by default) open one ticket, a trailing newline or another case in the address notwithstanding, and two sent at the same instant are told apart by the tenant's counter row. There is no session behind the form, so the sender is who the form says: anybody can type an address. The ticket carries its origin (contact_form), the customer can close a ticket they did not write, and an application that must not take a form from just anybody puts its own check (a captcha, a rate limit) in front of the call.

Publish the views

This package carries the markup of its own five pages and hands it over instead of rendering it from the inside, because a page you cannot edit is a page that says the wrong thing in your product.

Look at what would be written, then write it:

aru vendor:publish --tag=view
aru vendor:publish --tag=view --apply

Nothing is written without --apply. The preview lists every file as create, update, unchanged or conflict, and running the command a second time writes nothing. A file changed outside its arandu:begin custom markers is reported as a conflict and left alone; --force publishes over one, and even then what is inside the markers is carried forward.

The files land under resources/views/modules/ticket/, and from that point they are yours: the customer's index, form and show, and the team's admin/index and admin/show. Nothing of this package is compiled beside them, so no view name is registered twice and no rule has to decide which of two files won, and a view that changes later does not reach a project that already published it.

Two steps are left to you, and they are left to you because a command that edited bootstrap/app.go behind your back is a command whose output nobody can explain. Compile what was written:

aru view:build

and import the two directories it wrote into, with the other imports:

	_ "your/module/path/storage/framework/views/modules/ticket"
	_ "your/module/path/storage/framework/views/modules/ticket/admin"

Without those imports the views are not in the binary, and the module refuses to boot rather than answering the first request that reaches one of them with a 500. The refusal names the views, the command and the imports.

The pages extend your layouts.app and use the Kyse components (components.DataTable paged, searched, ordered and filtered on the server; Field, Textarea, Select, Badge, Empty). They draw no inline style and no inline script, so a content security policy of 'self' holds, and each screen was drawn at 390 px without horizontal scroll: a table scrolls inside its own container.

Configuration

field required meaning
TeamTenant yes the tenant the platform team signs in to.
Prefix no where the customer's routes are mounted. Defaults to /tickets.
TeamPrefix no where the team's routes are mounted. Defaults to /admin/tickets.
Paths no the segments of every route. Empty fields keep DefaultPaths; PortuguesePaths is the set in pt-BR.
PageSize no rows per page. Defaults to 25, refused above 100.
Middleware, TeamMiddleware no run after RequireAuth, on every route and on the team's only. A guard says "this request is on the right side" and stops; the policy still decides.
Accounts no the AccountResolver. Without it OpenFromContact refuses.
Directory no names people and lists the team.
Notifier no told about a ticket opened and a reply. Nil tells nobody.
Activity no arandu-activitylog's logger. Nil records nothing.
Markdown no the application's arandu-markdown module. Configure it with Images: markdown.ImagesNone and LinkRel: "noopener nofollow ugc", as a text people wrote deserves. Nil shows the text escaped.
Files no the private filesystem.Disk for attachments. Nil turns attachments off.
MaxAttachmentBytes no the largest file. Defaults to 5 MiB, refused above 25 MiB.
MaxAttachments no files per message. Defaults to 5, refused above 10.
ContactWindow no how long the same form opens one ticket. Defaults to ten minutes, refused above a day.
Locale no the language of screens, messages and log descriptions. Defaults to pt-BR; en is shipped too.
Translator no the application's own catalogue, asked before the shipped one, key by key (ticket.screen.index_title).
TitleSeparator no joins a screen's title to APP_NAME in the document title.
Location no the timezone instants are written in. Defaults to America/Sao_Paulo.
Chrome no what the application's layout draws around a screen, carried on the page as Screen.Chrome().
Now, Logger no the clock, and where a notifier's failure is logged.

New returns an error rather than starting half-wired: a prefix in the framework's namespace, two routes that collide, a limit out of range or a team tenant that cannot be one fail where they are written.

Routes

Every route sits behind RequireAuth: a visitor with no session is sent to /auth/login, and a handler reads who is asking with ctx.User(). The paths below are the defaults; Config.Prefix, Config.TeamPrefix and Config.Paths move them, and the route names never change. Module.URLs() builds every address, for an application that links to the screens from its own pages.

method path name
GET /tickets ticket.index the customer's list
GET /tickets/new ticket.create the form
POST /tickets ticket.store open one
GET /tickets/{id} ticket.show the conversation
POST /tickets/{id}/replies ticket.reply reply
POST /tickets/{id}/closure ticket.close close
POST /tickets/{id}/reopening ticket.reopen reopen
GET /tickets/{id}/attachments/{attachment} ticket.attachment a file
GET /admin/tickets ticket.team.index the team's list
GET /admin/tickets/{id} ticket.team.show the conversation, with its notes
POST /admin/tickets/{id}/replies ticket.team.reply answer the customer
POST /admin/tickets/{id}/notes ticket.team.note an internal note
POST /admin/tickets/{id}/status ticket.team.status change the status
POST /admin/tickets/{id}/priority ticket.team.priority change the priority
POST /admin/tickets/{id}/assignee ticket.team.assign assign, or take back
GET /admin/tickets/{id}/attachments/{attachment} ticket.team.attachment a file

Every GET answers the screen to a browser and JSON to a client that asks for it (Accept: application/json); every form posts to the same routes the JSON clients use, and a write answers 201/200 with the resource to a client that asks for JSON, and redirects to the conversation otherwise. The list takes q, status (repeatable), sort, dir and page; the team's also priority (repeatable), assignee (a member's id, me, or none) and space. A sort the list does not offer, or a status that is not one, is dropped.

status what happened
303 to /auth/login there is no session on the request
403 the policy refused: the subject was not granted the action, or is not on the team
404 no ticket with that id for this subject: it does not exist, it is in another space, or it is somebody else's
303 back to the page the request came from the input was rejected; the messages (in Portuguese by default) and what was typed are in the flash
422 application/problem+json the input was rejected and the request asked for JSON; the messages are in errors, keyed by field

The package does not choose these: its handlers return what the service returned, and the framework's router answers it. A refusal carries no detail beyond the status and its standard sentence.

Statuses

status meaning moves to
open nobody on the team answered yet waiting_customer when the team answers
waiting_customer the team answered waiting_team when the customer replies
waiting_team the customer answered waiting_customer when the team answers
resolved the team considers it answered waiting_team when the customer replies or reopens
closed nobody replies until it is reopened waiting_team when the customer reopens

The customer replying to an open ticket leaves it open. The team can set any status by hand; the closing instant follows the status. An internal note moves nothing, tells nobody, and can be written on a closed ticket.

Attachments

A message may carry up to MaxAttachments files, each up to MaxAttachmentBytes: PNG, JPEG, GIF, WebP, PDF or plain text, by what the bytes are and not by what the sender called them or announced (a script sent as .png is refused). They are kept on Config.Files under a key the server generates (ticket/attachments/<id>/<sha256>.<ext>, below the tenant's own prefix), never under a name the sender typed. The bucket is private: a file is read only through the download route, which asks the policy, answers a file of an internal note as not found to the customer, serves it as an attachment with the detected type, nosniff and a sandbox policy, and records the reading in the activity log.

In Arandu applications Config.Files is the filesystem.Disk of FILESYSTEM_DISK=r2 (Cloudflare R2 over the S3 protocol, one bucket per project and environment); the local disk is for tests.

What is recorded

With Config.Activity the log ticket holds one entry per act that changes something, about the ticket, with the number and the old and new value where there is one:

ticket.opened, ticket.replied, ticket.status_changed, ticket.priority_changed, ticket.assigned, ticket.closed, ticket.reopened, ticket.note_added, and ticket.attachment_read.

An act of the team is written in the customer's tenant, with the team member as its causer, so it is in the log of the space it happened in and in the platform administrator's read of every tenant's. No entry carries the text of a message; the entry of a note says that one was written.

The data path

Ticket, Message and Attachment embed model.Model, and each table is declared once, beside the entity, in model.go. aru model:build generates TicketQuery.go and the others from them: Tickets(db), which starts a *TicketQuery, and TicketCollection. The files are committed and never edited by hand; regenerate them after changing an entity, and aru model:build --check fails when one is out of date.

TicketService and TeamService own *data.DB and follow validate -> security.Authorize -> Grant -> Model terminal; handlers never hold the database or start a query. The tenant of every write is data.Tenant(g), from the Grant, never from the path, the body, the query string or a header.

Three Grants are issued without a signed-in customer, all in grants.go and nowhere else, each after something else decided:

  • the contact form's, after the application's AccountResolver named the account;
  • the team's, scoped to the one tenant a ticket is in, after TeamPolicy allowed the member and only for the action the realm's Grant was issued for;
  • the one that records a file being read.

The team's list reads the tickets table without the tenant filter, through internal/platform, which no application can import. Its rows never leave the service as anything but snapshots with no connection, and every write goes through the tenant's own model.

Layout

module.go        registration, routes, the contract with the framework
config.go        what the application passes in, and the route table
contracts.go     what the application implements: accounts, directory, notifier
model.go         the entities, their own rules, and what they may answer with
policy.go        who may do what, for a customer and for the team
service.go       the customer's use cases, and Model access after authorization
team.go          the team's use cases
contact.go       opening a ticket from the contact form
compose.go       storing a ticket, a message and its files
attachments.go   checking and keeping files
grants.go        the three Grants issued without a customer
activity.go      the activity log and the notifier
handlers.go      the controllers of both sides
pages.go         what the screens are handed
screens.go       building it from what a service answered
migrations.go    the schema
translation.go   the sentences, in resources/lang
views.go         the files the application takes ownership of

What is not here

  • E-mail: the package sends none and reads none. OriginEmail is a value an application can open a ticket with; receiving mail is the application's.
  • SLAs, ticket merging, categories as a managed list, canned replies, and attachments on the contact form.
  • A second way to decide who may do what, a local role table or a balance: permissions are arandu-permission's, activity is arandu-activitylog's.

Tests

export GOWORK=off
go test -race ./...

The denial suite constructs the services with a nil database, so even building Tickets(nil) would panic. The structural twin reads every exported service method and checks the same order on the allowed path, that a system Grant is issued in grants.go alone, that the table that reads every tenant is reached only after the team's policy answered, and that one place writes unescaped HTML. The feature suite runs the whole thing over a real SQLite database, and over the router with sessions. The views are published into a scratch project, compiled with the view compiler an application runs, and drawn once with sample data.

Licence

MIT. See LICENSE.md. Copyright HYZIS.

Documentation

Overview

Package ticket is an Arandu module: internal support tickets, for the customers of a multi-tenant application and for the platform team that answers them.

A ticket is a conversation between a user of a tenant -- a space -- and the team. The customer lists their own tickets, opens one, reads the conversation, replies, closes and reopens. The team lists every space's tickets, reads the conversation with its internal notes, answers, writes notes the customer never sees, and changes a ticket's status, priority and assignee. The two sides are separate: each has its own controllers, its own screens and its own prefix, and the application mounts each where its own session is valid.

The files are laid out by role rather than by layer, so the whole package reads top to bottom:

module.go      -> registration, routes and the contract with the framework
config.go      -> what the application passes in, and the route table
contracts.go   -> what the application implements: accounts, directory, notifier
model.go       -> the entities, their own rules, and what they may answer with
policy.go      -> who may do what, for a customer and for the team
service.go     -> the customer's use cases, and Model access after authorization
team.go        -> the team's use cases
contact.go     -> opening a ticket from the application's contact form
compose.go     -> storing a ticket, a message and its files
attachments.go -> checking and keeping files on the application's Disk
grants.go      -> the three places a Grant is issued without a signed-in customer
activity.go    -> what is recorded in arandu-activitylog, and what is told
handlers.go    -> the controllers of both sides
pages.go       -> what the screens are handed
migrations.go  -> the schema
views.go       -> the files the application takes ownership of

An application registers it explicitly. There is no service provider, no container and no discovery: the wiring is the lines somebody wrote, and reading them is how they learn what the application is made of.

Index

Constants

View Source
const (
	ActivityTicketOpened          = "ticket.opened"
	ActivityTicketReplied         = "ticket.replied"
	ActivityTicketStatusChanged   = "ticket.status_changed"
	ActivityTicketPriorityChanged = "ticket.priority_changed"
	ActivityTicketAssigned        = "ticket.assigned"
	ActivityTicketClosed          = "ticket.closed"
	ActivityTicketReopened        = "ticket.reopened"
	ActivityTicketNoteAdded       = "ticket.note_added"
	ActivityAttachmentRead        = "ticket.attachment_read"
)

The events this package records, one per act that changes something, and one for a file being read. The subject of each entry is the ticket.

No entry carries the text of a message, and the entry of an internal note carries nothing of the note: that an act happened is what the log keeps, and what was said stays in the conversation, where the side that may read it reads it.

View Source
const (
	// DefaultPrefix is where the customer's routes are mounted when Config
	// leaves Prefix empty.
	DefaultPrefix = "/tickets"
	// DefaultTeamPrefix is where the team's routes are mounted when Config
	// leaves TeamPrefix empty.
	DefaultTeamPrefix = "/admin/tickets"
	// DefaultPageSize is how many rows one page of a list answers with when
	// Config leaves PageSize at zero.
	DefaultPageSize = 25
	// MaxPageSize is the ceiling PageSize is refused above. A page nobody
	// bounded is a page that reads the whole table on the day the table is
	// large.
	MaxPageSize = 100
	// DefaultLocale is the language of the screens, the messages and the
	// activity descriptions when Config.Locale names none.
	DefaultLocale = "pt-BR"
	// FallbackLocale answers every line the locale in use does not have.
	FallbackLocale = "en"
	// DefaultContactWindow is how long the same e-mail, subject and body open
	// one ticket and not two.
	DefaultContactWindow = 10 * time.Minute
	// MaxContactWindow is the ceiling ContactWindow is refused above: past a
	// day the same words are a new request.
	MaxContactWindow = 24 * time.Hour
	// DefaultMaxAttachmentBytes is the size of the largest file one message
	// carries when Config leaves MaxAttachmentBytes at zero.
	DefaultMaxAttachmentBytes = 5 << 20
	// MaxAttachmentBytesLimit is the ceiling MaxAttachmentBytes is refused
	// above: the file is read into memory to be looked at before it is kept.
	MaxAttachmentBytesLimit = 25 << 20
	// DefaultMaxAttachments is how many files one message carries when Config
	// leaves MaxAttachments at zero.
	DefaultMaxAttachments = 5
	// MaxAttachmentsLimit is the ceiling MaxAttachments is refused above.
	MaxAttachmentsLimit = 10
	// MaxBodyChars is the longest message body, in characters. Markdown is
	// rendered in a pass as long as the text, so a field that takes text from
	// people the application does not trust caps its length.
	MaxBodyChars = 10000
)

The defaults for the optional settings. They are constants rather than literals inside Config.withDefaults, so the value a reader finds here is the value the package uses.

View Source
const (
	ViewIndex     = "modules.ticket.index"
	ViewNew       = "modules.ticket.form"
	ViewShow      = "modules.ticket.show"
	ViewTeamIndex = "modules.ticket.admin.index"
	ViewTeamShow  = "modules.ticket.admin.show"
)

The screens this package renders, by the name their path gives them.

View Source
const (
	// TicketCreate is opening a ticket in the subject's own space.
	TicketCreate security.Action = "ticket.create"
	// TicketViewOwn is reading the tickets the subject asked for themselves.
	TicketViewOwn security.Action = "ticket.view_own"
	// TicketViewSpace is reading every ticket of the subject's space, whoever
	// asked: for the people who run the space, beside those they run it for.
	TicketViewSpace security.Action = "ticket.view_space"
	// TicketReply is adding a message to a ticket the subject can read.
	TicketReply security.Action = "ticket.reply"
	// TicketClose is closing a ticket the subject can read, and reopening it.
	TicketClose security.Action = "ticket.close"

	// TicketViewAll is the team reading every ticket of every space, internal
	// notes included.
	TicketViewAll security.Action = "ticket.view_all"
	// TicketManage is the team answering a customer and changing a ticket's
	// status and priority.
	TicketManage security.Action = "ticket.manage"
	// TicketAssign is the team giving a ticket to a member of the team.
	TicketAssign security.Action = "ticket.assign"
	// TicketNote is the team writing a note only the team reads.
	TicketNote security.Action = "ticket.note"

	// TicketOpenFromContact is the application's contact form opening a ticket
	// for the account an e-mail belongs to. It is not a subject's action: no
	// person holds it, and OpenFromContact is the only place that issues it.
	TicketOpenFromContact security.Action = "ticket.open_from_contact"
)

The actions of this package. Constants rather than strings at the call site: a typo in an action name would silently authorize nothing, or worse, everything.

They carry the entity in the name because an application registers many packages, and the name of an action shows up in logs and in audit trails where "view" on its own says nothing about what was viewed.

The actions are what an arandu-permission group carries: the application declares them in the catalogue (Actions and TeamActions), an organization or the platform grants them to whom it decides, and arandu-permission fills the subject's actions on every request. Nothing here is decided by a role.

View Source
const ActivityLogName = "ticket"

ActivityLogName is the log every entry of this package is recorded in.

View Source
const ActivityRefType = "ticket"

ActivityRefType is the kind of subject an entry about a ticket names.

View Source
const AssigneeNone = "none"

AssigneeNone is TeamQuery.Assignee for the tickets nobody took.

View Source
const DefaultLocation = "America/Sao_Paulo"

DefaultLocation is the timezone the screens write instants in when Config leaves Location nil. The package is Brazilian first; an application elsewhere sets its own.

View Source
const PublishCommand = "aru vendor:publish --apply"

PublishCommand is what an application runs to take ownership of the views this package offers.

It is spelled out as a constant so that whatever says it -- the refusal below, or a message an application writes for its own operators -- says one thing. A person who is told two different commands for one job tries both.

The command reads the modules the application registered and writes what each one declares, which is why it is the application's command and not this package's: nothing outside the application knows which modules it holds.

View Source
const TranslationGroup = "ticket"

TranslationGroup is the file every key of this package lives in, and the prefix of every key: "ticket.screen.index_title".

Variables

View Source
var DefaultPaths = Paths{
	New: "/new", Replies: "/replies", Closure: "/closure", Reopening: "/reopening", Attachments: "/attachments",
	Notes: "/notes", Status: "/status", Priority: "/priority", Assignee: "/assignee",
}

DefaultPaths are the English segments.

View Source
var ErrNoAccount = errors.New("ticket: no account has that e-mail")

ErrNoAccount is what an AccountResolver answers when no account has the e-mail, and what OpenFromContact returns for it: the contact form opens a ticket only for somebody who has an account, and for anybody else the application keeps doing what it did before.

View Source
var ErrNotFound = fmt.Errorf("ticket: record not found: %w", model.ErrModelNotFound)

ErrNotFound is returned when no row matches, including when the row exists in another tenant. The two cases are deliberately indistinguishable.

It wraps model.ErrModelNotFound, so the router answers it with 404 and a caller can still ask for it by name with errors.Is.

View Source
var PortuguesePaths = Paths{
	New: "/novo", Replies: "/respostas", Closure: "/encerramento", Reopening: "/reabertura", Attachments: "/anexos",
	Notes: "/notas", Status: "/situacao", Priority: "/prioridade", Assignee: "/responsavel",
}

PortuguesePaths are the segments in Brazilian Portuguese, for an application whose addresses are written in it. Mounted at "/painel/chamados" and "/admin/chamados" they read /painel/chamados/novo, /painel/chamados/{id}/respostas and /admin/chamados/{id}/responsavel.

Priorities are every priority, lowest first.

Statuses are every status, in the order the screens list them.

Functions

func Actions

func Actions() []security.Action

Actions are the customer's actions, for an application to declare in arandu-permission's catalogue beside its own, so an organization grants them to whom it decides.

func AttachmentTypes

func AttachmentTypes() []string

AttachmentTypes are the content types a message may carry, sorted.

func Lines

func Lines(locale string) translation.Lines

Lines are the sentences this package ships for one locale, keyed the way a translator asks for them ("ticket.validation.required"), and nil for a locale it does not ship. An application reads them to see what there is to override, or to check a catalogue of its own against them.

func Locales

func Locales() []string

Locales are the languages the package ships.

func PriorityVariant

func PriorityVariant(key string) string

PriorityVariant is the badge variant a priority wears: the ordinary ones are quiet, the ones that ask for haste are not.

func Prose

func Prose(message MessageRow) template.HTML

Prose is a message's text as markup a page may write unescaped. It is a component in the sense the view compiler's audit means: a function returning template.HTML whose input was made safe when the row was built -- escaped, or rendered and sanitized by arandu-markdown -- and so is the one door through which text a person typed reaches a page as markup.

func PublishedPaths

func PublishedPaths() []string

PublishedPaths are the files in the archive, each relative to the root of the application, sorted.

func StatusVariant

func StatusVariant(key string) string

StatusVariant is the badge variant a status wears: the one that waits on the customer stands out, the ones that are over fade.

func TeamActions

func TeamActions() []security.Action

TeamActions are the platform team's actions. They are declared apart from Actions so a catalogue an organization grants from never holds them: an application gives them to the groups of its own team only, and TeamPolicy refuses them to any subject outside Config.TeamTenant besides.

func ViewNames

func ViewNames() []string

ViewNames are the names the published views are rendered by, sorted.

The name is the path under the view root with its separators turned into dots, which is what the view compiler writes into the registration call. It is derived from the same archive the publication carries, so a view that was renamed cannot keep an old name here.

func ViewPackages

func ViewPackages() []string

ViewPackages are the directories the compiled views land in, each relative to the root of the application, sorted and without repeats.

Importing one is what puts its views in the binary: a compiled view calls the registry from init(), and a package nothing imports is not linked at all. The import is named rather than written into bootstrap/app.go, because one line somebody reads beats a file that changed while they were not looking.

Types

type Account

type Account struct {
	// UserID is the user. It becomes the requester of the ticket.
	UserID string
	// Tenant is the space the user's tickets live in. It is the application's
	// answer and nothing a request names, and it has to be a valid tenant.
	Tenant string
	// Name and Email are how the user is called and reached. Email is the one
	// the account has, which can differ in case from the one the form carried.
	Name  string
	Email string
}

Account is the user an e-mail belongs to, and the tenant they are a member of.

type AccountResolver

type AccountResolver interface {
	// Resolve returns the account that has the e-mail, or ErrNoAccount.
	Resolve(ctx context.Context, email string) (Account, error)
}

AccountResolver finds the account an e-mail belongs to.

It is the application's: it reads its own users. OpenFromContact asks it and trusts the answer, so it answers only for an address that is an account's own, and ErrNoAccount for any other.

type Attachment

type Attachment struct {
	model.Model

	// ID is the identifier.
	ID string `db:"id"`

	// TenantID is the tenant of the ticket the file belongs to.
	TenantID string `db:"tenant_id"`

	// TicketID and MessageID say where the file was attached.
	TicketID  string `db:"ticket_id"`
	MessageID string `db:"message_id"`

	// Key is where the Disk keeps the bytes. It is generated by the server and
	// never shown.
	Key string `db:"file_key"`

	// Name is the name the customer's file had, cut to a bare file name.
	Name string `db:"file_name"`

	// ContentType is what the bytes are, as detected and not as announced.
	ContentType string `db:"content_type"`

	// Size is the number of bytes.
	Size int64 `db:"size"`

	// SHA256 is the digest of the bytes, in hex.
	SHA256 string `db:"sha256"`

	// CreatedAt is when it was stored, in UTC.
	CreatedAt time.Time `db:"created_at"`
}

Attachment is a file kept for a message. The bytes live on the application's Disk, under a key the server generated; the row says which and what they are.

func (*Attachment) Fresh

func (row *Attachment) Fresh(ctx context.Context, g auth.Grant, with ...string) (*Attachment, error)

Fresh reads the row again, as a new Attachment; it shadows the untyped one the embedded model promotes.

func (*Attachment) Replicate

func (row *Attachment) Replicate(except ...string) (*Attachment, error)

Replicate returns an unsaved copy of the row without its key; it shadows the untyped one the embedded model promotes.

type AttachmentCollection

type AttachmentCollection []*Attachment

AttachmentCollection is rows of Attachment, as a query returns them.

func AttachmentCollectionOf

func AttachmentCollectionOf(rows model.Rows) AttachmentCollection

AttachmentCollectionOf converts rows of the table of Attachment. A nil input is a nil collection.

func (AttachmentCollection) Contains

func (c AttachmentCollection) Contains(key any) bool

func (AttachmentCollection) Except

func (c AttachmentCollection) Except(keys ...any) AttachmentCollection

func (AttachmentCollection) Find

func (c AttachmentCollection) Find(key any) *Attachment

func (AttachmentCollection) FindOrFail

func (c AttachmentCollection) FindOrFail(key any) (*Attachment, error)

func (AttachmentCollection) Fresh

func (AttachmentCollection) Load

func (c AttachmentCollection) Load(ctx context.Context, g auth.Grant, relations ...string) error

func (AttachmentCollection) LoadCount

func (c AttachmentCollection) LoadCount(ctx context.Context, g auth.Grant, relations ...string) error

func (AttachmentCollection) LoadMissing

func (c AttachmentCollection) LoadMissing(ctx context.Context, g auth.Grant, relations ...string) error

func (AttachmentCollection) ModelKeys

func (c AttachmentCollection) ModelKeys() []any

func (AttachmentCollection) Only

func (AttachmentCollection) Pluck

func (c AttachmentCollection) Pluck(column string) []any

func (AttachmentCollection) Rows

func (c AttachmentCollection) Rows() model.Rows

Rows converts the collection back to the core's rows.

func (AttachmentCollection) ToArray

func (c AttachmentCollection) ToArray() []map[string]any

func (AttachmentCollection) Unique

type AttachmentQuery

type AttachmentQuery struct {
	// contains filtered or unexported fields
}

AttachmentQuery is a query on the table of Attachment.

The methods that build return the query, so they chain. The methods that run take a context and an auth.Grant, and return Attachment rows or a value; there is no way to run one without a Grant.

func Attachments

func Attachments(db model.DB) *AttachmentQuery

Attachments starts a query on the table of Attachment, through db.

Global scopes are on and, unless the table is Global, every statement is filtered by the tenant of the Grant its terminal takes.

func (*AttachmentQuery) AddSelect

func (q *AttachmentQuery) AddSelect(columns ...any) *AttachmentQuery

func (*AttachmentQuery) Avg

func (q *AttachmentQuery) Avg(ctx context.Context, g auth.Grant, column any) (any, error)

func (*AttachmentQuery) Base

func (q *AttachmentQuery) Base() *model.Builder

Base returns the core builder the query forwards to. It is for code in this package -- a local scope in the custom block of model.go -- and for the factory, which builds rows through it.

func (*AttachmentQuery) Chunk

func (q *AttachmentQuery) Chunk(ctx context.Context, g auth.Grant, count int, callback func(AttachmentCollection, int) (bool, error)) error

func (*AttachmentQuery) Clone

func (q *AttachmentQuery) Clone() *AttachmentQuery

Clone returns an independent copy of the query.

func (*AttachmentQuery) Count

func (q *AttachmentQuery) Count(ctx context.Context, g auth.Grant, columns ...any) (int64, error)

func (*AttachmentQuery) Create

func (q *AttachmentQuery) Create(ctx context.Context, g auth.Grant, attributes map[string]any) (*Attachment, error)

func (*AttachmentQuery) Cursor

func (*AttachmentQuery) CursorPaginate

func (q *AttachmentQuery) CursorPaginate(ctx context.Context, g auth.Grant, perPage int, cursor *pagination.Cursor, opts pagination.Options, columns ...any) (AttachmentCollection, *pagination.CursorPage, error)

func (*AttachmentQuery) Decrement

func (q *AttachmentQuery) Decrement(ctx context.Context, g auth.Grant, column string, amount any, extra map[string]any) (int64, error)

func (*AttachmentQuery) Delete

func (q *AttachmentQuery) Delete(ctx context.Context, g auth.Grant) (int64, error)

func (*AttachmentQuery) DoesntExist

func (q *AttachmentQuery) DoesntExist(ctx context.Context, g auth.Grant) (bool, error)

func (*AttachmentQuery) DoesntHave

func (q *AttachmentQuery) DoesntHave(relation, boolean string, callback func(*query.Builder)) *AttachmentQuery

func (*AttachmentQuery) Each

func (q *AttachmentQuery) Each(ctx context.Context, g auth.Grant, count int, callback func(*Attachment, int) (bool, error)) error

func (*AttachmentQuery) Exists

func (q *AttachmentQuery) Exists(ctx context.Context, g auth.Grant) (bool, error)

func (*AttachmentQuery) Find

func (q *AttachmentQuery) Find(ctx context.Context, g auth.Grant, id any, columns ...any) (*Attachment, error)

func (*AttachmentQuery) FindMany

func (q *AttachmentQuery) FindMany(ctx context.Context, g auth.Grant, ids []any, columns ...any) (AttachmentCollection, error)

func (*AttachmentQuery) FindOrFail

func (q *AttachmentQuery) FindOrFail(ctx context.Context, g auth.Grant, id any, columns ...any) (*Attachment, error)

func (*AttachmentQuery) FindOrNew

func (q *AttachmentQuery) FindOrNew(ctx context.Context, g auth.Grant, id any, columns ...any) (*Attachment, error)

func (*AttachmentQuery) First

func (q *AttachmentQuery) First(ctx context.Context, g auth.Grant, columns ...any) (*Attachment, error)

First returns the first row, or nil and no error when there is none.

func (*AttachmentQuery) FirstOrCreate

func (q *AttachmentQuery) FirstOrCreate(ctx context.Context, g auth.Grant, attributes, values map[string]any) (*Attachment, error)

func (*AttachmentQuery) FirstOrFail

func (q *AttachmentQuery) FirstOrFail(ctx context.Context, g auth.Grant, columns ...any) (*Attachment, error)

func (*AttachmentQuery) FirstOrNew

func (q *AttachmentQuery) FirstOrNew(ctx context.Context, g auth.Grant, attributes, values map[string]any) (*Attachment, error)

func (*AttachmentQuery) FirstWhere

func (q *AttachmentQuery) FirstWhere(ctx context.Context, g auth.Grant, column any, args ...any) (*Attachment, error)

func (*AttachmentQuery) ForPage

func (q *AttachmentQuery) ForPage(page, perPage int) *AttachmentQuery

func (*AttachmentQuery) ForceCreate

func (q *AttachmentQuery) ForceCreate(ctx context.Context, g auth.Grant, attributes map[string]any) (*Attachment, error)

func (*AttachmentQuery) Get

func (q *AttachmentQuery) Get(ctx context.Context, g auth.Grant, columns ...any) (AttachmentCollection, error)

Get runs the query and returns the rows.

func (*AttachmentQuery) GroupBy

func (q *AttachmentQuery) GroupBy(groups ...any) *AttachmentQuery

func (*AttachmentQuery) Has

func (q *AttachmentQuery) Has(relation, operator string, count int, boolean string, callback func(*query.Builder)) *AttachmentQuery

func (*AttachmentQuery) Increment

func (q *AttachmentQuery) Increment(ctx context.Context, g auth.Grant, column string, amount any, extra map[string]any) (int64, error)

func (*AttachmentQuery) Insert

func (q *AttachmentQuery) Insert(ctx context.Context, g auth.Grant, values ...map[string]any) (bool, error)

func (*AttachmentQuery) InsertOrIgnore

func (q *AttachmentQuery) InsertOrIgnore(ctx context.Context, g auth.Grant, values ...map[string]any) (bool, error)

func (*AttachmentQuery) Join

func (q *AttachmentQuery) Join(table any, first any, args ...any) *AttachmentQuery

func (*AttachmentQuery) Latest

func (q *AttachmentQuery) Latest(column ...string) *AttachmentQuery

func (*AttachmentQuery) Limit

func (q *AttachmentQuery) Limit(value int) *AttachmentQuery

func (*AttachmentQuery) Max

func (q *AttachmentQuery) Max(ctx context.Context, g auth.Grant, column any) (any, error)

func (*AttachmentQuery) Min

func (q *AttachmentQuery) Min(ctx context.Context, g auth.Grant, column any) (any, error)

func (*AttachmentQuery) New

func (q *AttachmentQuery) New() (*Attachment, error)

New returns an empty Attachment on the query's connection, not yet saved, filled with what WithAttributes recorded on the query.

func (*AttachmentQuery) Offset

func (q *AttachmentQuery) Offset(value int) *AttachmentQuery

func (*AttachmentQuery) Oldest

func (q *AttachmentQuery) Oldest(column ...string) *AttachmentQuery

func (*AttachmentQuery) OrDoesntHave

func (q *AttachmentQuery) OrDoesntHave(relation string) *AttachmentQuery

func (*AttachmentQuery) OrHas

func (q *AttachmentQuery) OrHas(relation, operator string, count int) *AttachmentQuery

func (*AttachmentQuery) OrWhere

func (q *AttachmentQuery) OrWhere(column any, args ...any) *AttachmentQuery

OrWhere adds an or-where clause; a func(*AttachmentQuery) in place of the column adds a group.

func (*AttachmentQuery) OrWhereDoesntHave

func (q *AttachmentQuery) OrWhereDoesntHave(relation string, callback func(*query.Builder)) *AttachmentQuery

func (*AttachmentQuery) OrWhereHas

func (q *AttachmentQuery) OrWhereHas(relation string, callback func(*query.Builder)) *AttachmentQuery

func (*AttachmentQuery) OrWhereNot

func (q *AttachmentQuery) OrWhereNot(column any, args ...any) *AttachmentQuery

OrWhereNot adds an or-joined negated where clause, or a negated group.

func (*AttachmentQuery) OrWhereRelation

func (q *AttachmentQuery) OrWhereRelation(relation string, column any, args ...any) *AttachmentQuery

func (*AttachmentQuery) OrderBy

func (q *AttachmentQuery) OrderBy(column any, direction ...string) *AttachmentQuery

func (*AttachmentQuery) OrderByDesc

func (q *AttachmentQuery) OrderByDesc(column any) *AttachmentQuery

func (*AttachmentQuery) Paginate

func (q *AttachmentQuery) Paginate(ctx context.Context, g auth.Grant, perPage, page int, opts pagination.Options, columns ...any) (AttachmentCollection, *pagination.LengthAwarePage, error)

func (*AttachmentQuery) Pluck

func (q *AttachmentQuery) Pluck(ctx context.Context, g auth.Grant, column string) ([]any, error)

func (*AttachmentQuery) Select

func (q *AttachmentQuery) Select(columns ...any) *AttachmentQuery

func (*AttachmentQuery) SelectRaw

func (q *AttachmentQuery) SelectRaw(expression string, bindings ...any) *AttachmentQuery

func (*AttachmentQuery) SimplePaginate

func (q *AttachmentQuery) SimplePaginate(ctx context.Context, g auth.Grant, perPage, page int, opts pagination.Options, columns ...any) (AttachmentCollection, *pagination.Page, error)

func (*AttachmentQuery) Sole

func (q *AttachmentQuery) Sole(ctx context.Context, g auth.Grant, columns ...any) (*Attachment, error)

func (*AttachmentQuery) SoleValue

func (q *AttachmentQuery) SoleValue(ctx context.Context, g auth.Grant, column string) (any, error)

func (*AttachmentQuery) Sum

func (q *AttachmentQuery) Sum(ctx context.Context, g auth.Grant, column any) (any, error)

func (*AttachmentQuery) Touch

func (q *AttachmentQuery) Touch(ctx context.Context, g auth.Grant, column ...string) (int64, error)

func (*AttachmentQuery) Update

func (q *AttachmentQuery) Update(ctx context.Context, g auth.Grant, values map[string]any) (int64, error)

func (*AttachmentQuery) UpdateOrCreate

func (q *AttachmentQuery) UpdateOrCreate(ctx context.Context, g auth.Grant, attributes, values map[string]any) (*Attachment, error)

func (*AttachmentQuery) Upsert

func (q *AttachmentQuery) Upsert(ctx context.Context, g auth.Grant, values []map[string]any, uniqueBy, update []string) (int64, error)

func (*AttachmentQuery) Value

func (q *AttachmentQuery) Value(ctx context.Context, g auth.Grant, column string) (any, error)

func (*AttachmentQuery) ValueOrFail

func (q *AttachmentQuery) ValueOrFail(ctx context.Context, g auth.Grant, column string) (any, error)

func (*AttachmentQuery) Where

func (q *AttachmentQuery) Where(column any, args ...any) *AttachmentQuery

Where adds a where clause; a func(*AttachmentQuery) in place of the column adds a group.

func (*AttachmentQuery) WhereBelongsTo

func (q *AttachmentQuery) WhereBelongsTo(relationshipName string, related ...model.Entity) *AttachmentQuery

func (*AttachmentQuery) WhereBetween

func (q *AttachmentQuery) WhereBetween(column any, from, to any) *AttachmentQuery

func (*AttachmentQuery) WhereColumn

func (q *AttachmentQuery) WhereColumn(first any, args ...any) *AttachmentQuery

func (*AttachmentQuery) WhereDoesntHave

func (q *AttachmentQuery) WhereDoesntHave(relation string, callback func(*query.Builder)) *AttachmentQuery

func (*AttachmentQuery) WhereExists

func (q *AttachmentQuery) WhereExists(callback func(*query.Builder)) *AttachmentQuery

func (*AttachmentQuery) WhereHas

func (q *AttachmentQuery) WhereHas(relation string, callback func(*query.Builder)) *AttachmentQuery

func (*AttachmentQuery) WhereHasCount

func (q *AttachmentQuery) WhereHasCount(relation string, callback func(*query.Builder), operator string, count int) *AttachmentQuery

func (*AttachmentQuery) WhereIn

func (q *AttachmentQuery) WhereIn(column any, values []any) *AttachmentQuery

func (*AttachmentQuery) WhereKey

func (q *AttachmentQuery) WhereKey(id any) *AttachmentQuery

func (*AttachmentQuery) WhereNot

func (q *AttachmentQuery) WhereNot(column any, args ...any) *AttachmentQuery

WhereNot adds a negated where clause, or a negated group.

func (*AttachmentQuery) WhereNotExists

func (q *AttachmentQuery) WhereNotExists(callback func(*query.Builder)) *AttachmentQuery

func (*AttachmentQuery) WhereNotIn

func (q *AttachmentQuery) WhereNotIn(column any, values []any) *AttachmentQuery

func (*AttachmentQuery) WhereNotNull

func (q *AttachmentQuery) WhereNotNull(columns ...any) *AttachmentQuery

func (*AttachmentQuery) WhereNull

func (q *AttachmentQuery) WhereNull(columns ...any) *AttachmentQuery

func (*AttachmentQuery) WhereRelation

func (q *AttachmentQuery) WhereRelation(relation string, column any, args ...any) *AttachmentQuery

func (*AttachmentQuery) With

func (q *AttachmentQuery) With(relations ...string) *AttachmentQuery

func (*AttachmentQuery) WithAvg

func (q *AttachmentQuery) WithAvg(relation, column string) *AttachmentQuery

func (*AttachmentQuery) WithCount

func (q *AttachmentQuery) WithCount(relations ...string) *AttachmentQuery

func (*AttachmentQuery) WithExists

func (q *AttachmentQuery) WithExists(relation string) *AttachmentQuery

func (*AttachmentQuery) WithMax

func (q *AttachmentQuery) WithMax(relation, column string) *AttachmentQuery

func (*AttachmentQuery) WithMin

func (q *AttachmentQuery) WithMin(relation, column string) *AttachmentQuery

func (*AttachmentQuery) WithSum

func (q *AttachmentQuery) WithSum(relation, column string) *AttachmentQuery

func (*AttachmentQuery) Without

func (q *AttachmentQuery) Without(relations ...string) *AttachmentQuery

func (*AttachmentQuery) WithoutGlobalScope

func (q *AttachmentQuery) WithoutGlobalScope(identifier string) *AttachmentQuery

func (*AttachmentQuery) WithoutGlobalScopes

func (q *AttachmentQuery) WithoutGlobalScopes(identifiers ...string) *AttachmentQuery

type AttachmentView

type AttachmentView struct {
	ID          string
	Name        string
	ContentType string
	Size        int64
}

AttachmentView is a file as a screen or a response describes it. The key it is kept under is not part of it: the bytes are read through the package's download route, which asks the policy first.

type ChromeRequest

type ChromeRequest struct {
	// Title is the screen's title, already in the screen's language, without
	// the application's name Config.TitleSeparator adds to the document title.
	Title string
	// Team says the screen is one of the team's, mounted under TeamPrefix.
	Team bool
	// Subject is who is asking, as RequireAuth and Config.Middleware left it.
	Subject security.Subject
}

ChromeRequest is what Config.Chrome is asked about one screen.

type Config

type Config struct {
	// TeamTenant is the tenant the platform's own team signs in to: the realm of
	// the operator console. A team member is a subject of this tenant who holds
	// the team's actions, and the team policy refuses any other tenant's
	// subject whatever actions they carry. It comes from the application's own
	// configuration, never from a request. Required.
	TeamTenant string

	// Prefix is the path the customer's routes are mounted under. Empty means
	// DefaultPrefix.
	Prefix string

	// TeamPrefix is the path the team's routes are mounted under. Empty means
	// DefaultTeamPrefix. The two sides are separate: the team's session is
	// valid on this side only, and the customer's on the other.
	TeamPrefix string

	// Paths are the segments every route is built from, under Prefix and
	// TeamPrefix. A field left empty keeps its DefaultPaths value;
	// PortuguesePaths is the whole set in Brazilian Portuguese.
	Paths Paths

	// PageSize is how many rows one page of a list answers with. Zero means
	// DefaultPageSize, and anything above MaxPageSize is refused rather than
	// clamped: a number somebody wrote and did not get is worse than a number
	// somebody wrote and was told about.
	PageSize int

	// Middleware runs on every route of this package after RequireAuth, in
	// order, and TeamMiddleware after it on the team's routes only. An
	// application whose permissions arandu-permission administers mounts its
	// Roles() in Middleware: RequireAuth puts the subject as the session
	// resolved it, without the actions arandu-permission resolves on each
	// request. A guard of the application's own side -- the operator console's
	// -- goes in TeamMiddleware. A guard answers "is this request on the right
	// side" and stops; the policy still decides.
	Middleware     []func(http.Handler) http.Handler
	TeamMiddleware []func(http.Handler) http.Handler

	// Accounts resolves an e-mail to an account, for OpenFromContact. Nil makes
	// OpenFromContact refuse: there is no account to open for.
	Accounts AccountResolver

	// Directory names people and lists the team. Optional; see Directory.
	Directory Directory

	// Notifier is told when a ticket is opened, when the customer replies and
	// when the team answers. Nil tells nobody. See Notifier.
	Notifier Notifier

	// Activity is arandu-activitylog's logger, the application's one instance
	// (activityModule.Logger()). Every act that changes something is recorded
	// under the Grant it was authorized with, in the log named ActivityLogName,
	// and so is every download of a file. Nil records nothing.
	Activity *activitylog.Logger

	// Markdown renders the text of a message as sanitized HTML. It is the
	// application's one arandu-markdown instance (markdownModule): configure it
	// with Images set to markdown.ImagesNone and LinkRel "noopener nofollow
	// ugc", as a body people wrote deserves. Nil shows the text escaped, with
	// its line breaks, which is safe and plain.
	Markdown *markdown.Module

	// Files is the Disk the attachments are kept on: the application's, which
	// in production is a private bucket. Every key is generated by the server
	// and every read is authorized and recorded; nothing is served by a public
	// address. Nil turns attachments off, and a message that carries a file is
	// refused.
	Files *filesystem.Disk

	// MaxAttachmentBytes is the size of the largest file one message carries.
	// Zero means DefaultMaxAttachmentBytes, and anything above
	// MaxAttachmentBytesLimit is refused.
	MaxAttachmentBytes int64

	// MaxAttachments is how many files one message carries. Zero means
	// DefaultMaxAttachments, and anything above MaxAttachmentsLimit is refused.
	MaxAttachments int

	// ContactWindow is how long the same e-mail, subject and body open one
	// ticket and not two. Zero means DefaultContactWindow.
	ContactWindow time.Duration

	// Translator is the application's own catalogue, asked before the one this
	// package ships, key by key: "ticket.screen.index_title". Optional.
	Translator *translation.Translator

	// Locale is the language every screen, validation message and activity
	// description of this package is written in. Empty means DefaultLocale. The
	// package ships the locales Locales() lists; any other reads the
	// application's Translator first and English after it.
	Locale string

	// TitleSeparator joins a screen's title to the application's name in the
	// document title: with " — ", the list is titled "Chamados — Alpr.sh". Empty
	// titles a screen with its own title alone.
	TitleSeparator string

	// Chrome is what the application's layout draws around a screen of this
	// package: its sidebar, its navigation, the account menu. It is called once
	// per screen, after RequireAuth and before the page is rendered, and what it
	// answers is carried on the page as Screen.Chrome, where a layout asserts
	// the interface it draws from. An error is returned as the handler's. Nil
	// draws the screens with the page hesape's view.New builds and nothing
	// more.
	Chrome func(ctx *fhttp.Context, req ChromeRequest) (any, error)

	// Location is the timezone an instant is written in on the screens: a ticket
	// written at 22:00 in Sao Paulo is shown at 22:00, though it is already the next
	// day in UTC. Nil means DefaultLocation.
	Location *time.Location

	// Now is the clock: every timestamp a row carries is read from it. Nil
	// means time.Now. A test holds it still; an application leaves it nil.
	Now func() time.Time

	// Logger receives what the package cannot return to anybody: a Notifier
	// that failed after the change was stored. Nil means slog.Default().
	Logger *slog.Logger
}

Config is what the application passes when it wires this package.

A typed struct rather than a map: a misspelled key in a map is a setting that silently keeps its default, and the failure shows up as behaviour nobody asked for rather than as an error. Here a field that does not exist does not compile.

It names no tenant for the customers, and that is deliberate. Every route sits behind RequireAuth, so the tenant of every customer statement is the one the Grant carries, from the session of whoever is asking; a tenant written here would be a second place for one to come from. The one tenant it does name is the team's own, TeamTenant, because the team is not a customer.

func (Config) Validate

func (c Config) Validate() error

Validate reports what the configuration cannot be used with.

It is called by New, so an application with a setting that cannot work fails where it is wired rather than on the first request that needed it.

type ContactInput

type ContactInput struct {
	// Email is the address the sender wrote. The ticket opens only if it is an
	// account's.
	Email string
	// Name is what the sender called themselves; the account's own name wins
	// when it has one.
	Name    string
	Subject string
	Body    string
}

ContactInput is what the application's public contact form carries.

type ContactResult

type ContactResult struct {
	// Ticket is the ticket the form opened, or the one it already had opened
	// when this is a repeat.
	Ticket *Ticket
	// Duplicate says the same e-mail, subject and body had already opened a
	// ticket inside Config.ContactWindow, and nothing new was written.
	Duplicate bool
}

ContactResult is what OpenFromContact answers.

type Conversation

type Conversation struct {
	Ticket   Summary
	Messages []MessageView
}

Conversation is a ticket with what was said on it, as the side asking may read it. The customer's never holds an internal note.

type Counter

type Counter struct {
	model.Model

	ID         string    `db:"id"`
	TenantID   string    `db:"tenant_id"`
	LastNumber int64     `db:"last_number"`
	UpdatedAt  time.Time `db:"updated_at"`
}

Counter is the last number a tenant's tickets were given. The row's key is the tenant, so there is one row to take a lock on and no way to hold two. It is bookkeeping: an application never reads or writes it.

func (*Counter) Fresh

func (row *Counter) Fresh(ctx context.Context, g auth.Grant, with ...string) (*Counter, error)

Fresh reads the row again, as a new Counter; it shadows the untyped one the embedded model promotes.

func (*Counter) Replicate

func (row *Counter) Replicate(except ...string) (*Counter, error)

Replicate returns an unsaved copy of the row without its key; it shadows the untyped one the embedded model promotes.

type CounterCollection

type CounterCollection []*Counter

CounterCollection is rows of Counter, as a query returns them.

func CounterCollectionOf

func CounterCollectionOf(rows model.Rows) CounterCollection

CounterCollectionOf converts rows of the table of Counter. A nil input is a nil collection.

func (CounterCollection) Contains

func (c CounterCollection) Contains(key any) bool

func (CounterCollection) Except

func (c CounterCollection) Except(keys ...any) CounterCollection

func (CounterCollection) Find

func (c CounterCollection) Find(key any) *Counter

func (CounterCollection) FindOrFail

func (c CounterCollection) FindOrFail(key any) (*Counter, error)

func (CounterCollection) Fresh

func (CounterCollection) Load

func (c CounterCollection) Load(ctx context.Context, g auth.Grant, relations ...string) error

func (CounterCollection) LoadCount

func (c CounterCollection) LoadCount(ctx context.Context, g auth.Grant, relations ...string) error

func (CounterCollection) LoadMissing

func (c CounterCollection) LoadMissing(ctx context.Context, g auth.Grant, relations ...string) error

func (CounterCollection) ModelKeys

func (c CounterCollection) ModelKeys() []any

func (CounterCollection) Only

func (c CounterCollection) Only(keys ...any) CounterCollection

func (CounterCollection) Pluck

func (c CounterCollection) Pluck(column string) []any

func (CounterCollection) Rows

func (c CounterCollection) Rows() model.Rows

Rows converts the collection back to the core's rows.

func (CounterCollection) ToArray

func (c CounterCollection) ToArray() []map[string]any

func (CounterCollection) Unique

type CounterQuery

type CounterQuery struct {
	// contains filtered or unexported fields
}

CounterQuery is a query on the table of Counter.

The methods that build return the query, so they chain. The methods that run take a context and an auth.Grant, and return Counter rows or a value; there is no way to run one without a Grant.

func Counters

func Counters(db model.DB) *CounterQuery

Counters starts a query on the table of Counter, through db.

Global scopes are on and, unless the table is Global, every statement is filtered by the tenant of the Grant its terminal takes.

func (*CounterQuery) AddSelect

func (q *CounterQuery) AddSelect(columns ...any) *CounterQuery

func (*CounterQuery) Avg

func (q *CounterQuery) Avg(ctx context.Context, g auth.Grant, column any) (any, error)

func (*CounterQuery) Base

func (q *CounterQuery) Base() *model.Builder

Base returns the core builder the query forwards to. It is for code in this package -- a local scope in the custom block of model.go -- and for the factory, which builds rows through it.

func (*CounterQuery) Chunk

func (q *CounterQuery) Chunk(ctx context.Context, g auth.Grant, count int, callback func(CounterCollection, int) (bool, error)) error

func (*CounterQuery) Clone

func (q *CounterQuery) Clone() *CounterQuery

Clone returns an independent copy of the query.

func (*CounterQuery) Count

func (q *CounterQuery) Count(ctx context.Context, g auth.Grant, columns ...any) (int64, error)

func (*CounterQuery) Create

func (q *CounterQuery) Create(ctx context.Context, g auth.Grant, attributes map[string]any) (*Counter, error)

func (*CounterQuery) Cursor

func (q *CounterQuery) Cursor(ctx context.Context, g auth.Grant) iter.Seq2[*Counter, error]

func (*CounterQuery) CursorPaginate

func (q *CounterQuery) CursorPaginate(ctx context.Context, g auth.Grant, perPage int, cursor *pagination.Cursor, opts pagination.Options, columns ...any) (CounterCollection, *pagination.CursorPage, error)

func (*CounterQuery) Decrement

func (q *CounterQuery) Decrement(ctx context.Context, g auth.Grant, column string, amount any, extra map[string]any) (int64, error)

func (*CounterQuery) Delete

func (q *CounterQuery) Delete(ctx context.Context, g auth.Grant) (int64, error)

func (*CounterQuery) DoesntExist

func (q *CounterQuery) DoesntExist(ctx context.Context, g auth.Grant) (bool, error)

func (*CounterQuery) DoesntHave

func (q *CounterQuery) DoesntHave(relation, boolean string, callback func(*query.Builder)) *CounterQuery

func (*CounterQuery) Each

func (q *CounterQuery) Each(ctx context.Context, g auth.Grant, count int, callback func(*Counter, int) (bool, error)) error

func (*CounterQuery) Exists

func (q *CounterQuery) Exists(ctx context.Context, g auth.Grant) (bool, error)

func (*CounterQuery) Find

func (q *CounterQuery) Find(ctx context.Context, g auth.Grant, id any, columns ...any) (*Counter, error)

func (*CounterQuery) FindMany

func (q *CounterQuery) FindMany(ctx context.Context, g auth.Grant, ids []any, columns ...any) (CounterCollection, error)

func (*CounterQuery) FindOrFail

func (q *CounterQuery) FindOrFail(ctx context.Context, g auth.Grant, id any, columns ...any) (*Counter, error)

func (*CounterQuery) FindOrNew

func (q *CounterQuery) FindOrNew(ctx context.Context, g auth.Grant, id any, columns ...any) (*Counter, error)

func (*CounterQuery) First

func (q *CounterQuery) First(ctx context.Context, g auth.Grant, columns ...any) (*Counter, error)

First returns the first row, or nil and no error when there is none.

func (*CounterQuery) FirstOrCreate

func (q *CounterQuery) FirstOrCreate(ctx context.Context, g auth.Grant, attributes, values map[string]any) (*Counter, error)

func (*CounterQuery) FirstOrFail

func (q *CounterQuery) FirstOrFail(ctx context.Context, g auth.Grant, columns ...any) (*Counter, error)

func (*CounterQuery) FirstOrNew

func (q *CounterQuery) FirstOrNew(ctx context.Context, g auth.Grant, attributes, values map[string]any) (*Counter, error)

func (*CounterQuery) FirstWhere

func (q *CounterQuery) FirstWhere(ctx context.Context, g auth.Grant, column any, args ...any) (*Counter, error)

func (*CounterQuery) ForPage

func (q *CounterQuery) ForPage(page, perPage int) *CounterQuery

func (*CounterQuery) ForceCreate

func (q *CounterQuery) ForceCreate(ctx context.Context, g auth.Grant, attributes map[string]any) (*Counter, error)

func (*CounterQuery) Get

func (q *CounterQuery) Get(ctx context.Context, g auth.Grant, columns ...any) (CounterCollection, error)

Get runs the query and returns the rows.

func (*CounterQuery) GroupBy

func (q *CounterQuery) GroupBy(groups ...any) *CounterQuery

func (*CounterQuery) Has

func (q *CounterQuery) Has(relation, operator string, count int, boolean string, callback func(*query.Builder)) *CounterQuery

func (*CounterQuery) Increment

func (q *CounterQuery) Increment(ctx context.Context, g auth.Grant, column string, amount any, extra map[string]any) (int64, error)

func (*CounterQuery) Insert

func (q *CounterQuery) Insert(ctx context.Context, g auth.Grant, values ...map[string]any) (bool, error)

func (*CounterQuery) InsertOrIgnore

func (q *CounterQuery) InsertOrIgnore(ctx context.Context, g auth.Grant, values ...map[string]any) (bool, error)

func (*CounterQuery) Join

func (q *CounterQuery) Join(table any, first any, args ...any) *CounterQuery

func (*CounterQuery) Latest

func (q *CounterQuery) Latest(column ...string) *CounterQuery

func (*CounterQuery) Limit

func (q *CounterQuery) Limit(value int) *CounterQuery

func (*CounterQuery) Max

func (q *CounterQuery) Max(ctx context.Context, g auth.Grant, column any) (any, error)

func (*CounterQuery) Min

func (q *CounterQuery) Min(ctx context.Context, g auth.Grant, column any) (any, error)

func (*CounterQuery) New

func (q *CounterQuery) New() (*Counter, error)

New returns an empty Counter on the query's connection, not yet saved, filled with what WithAttributes recorded on the query.

func (*CounterQuery) Offset

func (q *CounterQuery) Offset(value int) *CounterQuery

func (*CounterQuery) Oldest

func (q *CounterQuery) Oldest(column ...string) *CounterQuery

func (*CounterQuery) OrDoesntHave

func (q *CounterQuery) OrDoesntHave(relation string) *CounterQuery

func (*CounterQuery) OrHas

func (q *CounterQuery) OrHas(relation, operator string, count int) *CounterQuery

func (*CounterQuery) OrWhere

func (q *CounterQuery) OrWhere(column any, args ...any) *CounterQuery

OrWhere adds an or-where clause; a func(*CounterQuery) in place of the column adds a group.

func (*CounterQuery) OrWhereDoesntHave

func (q *CounterQuery) OrWhereDoesntHave(relation string, callback func(*query.Builder)) *CounterQuery

func (*CounterQuery) OrWhereHas

func (q *CounterQuery) OrWhereHas(relation string, callback func(*query.Builder)) *CounterQuery

func (*CounterQuery) OrWhereNot

func (q *CounterQuery) OrWhereNot(column any, args ...any) *CounterQuery

OrWhereNot adds an or-joined negated where clause, or a negated group.

func (*CounterQuery) OrWhereRelation

func (q *CounterQuery) OrWhereRelation(relation string, column any, args ...any) *CounterQuery

func (*CounterQuery) OrderBy

func (q *CounterQuery) OrderBy(column any, direction ...string) *CounterQuery

func (*CounterQuery) OrderByDesc

func (q *CounterQuery) OrderByDesc(column any) *CounterQuery

func (*CounterQuery) Paginate

func (q *CounterQuery) Paginate(ctx context.Context, g auth.Grant, perPage, page int, opts pagination.Options, columns ...any) (CounterCollection, *pagination.LengthAwarePage, error)

func (*CounterQuery) Pluck

func (q *CounterQuery) Pluck(ctx context.Context, g auth.Grant, column string) ([]any, error)

func (*CounterQuery) Select

func (q *CounterQuery) Select(columns ...any) *CounterQuery

func (*CounterQuery) SelectRaw

func (q *CounterQuery) SelectRaw(expression string, bindings ...any) *CounterQuery

func (*CounterQuery) SimplePaginate

func (q *CounterQuery) SimplePaginate(ctx context.Context, g auth.Grant, perPage, page int, opts pagination.Options, columns ...any) (CounterCollection, *pagination.Page, error)

func (*CounterQuery) Sole

func (q *CounterQuery) Sole(ctx context.Context, g auth.Grant, columns ...any) (*Counter, error)

func (*CounterQuery) SoleValue

func (q *CounterQuery) SoleValue(ctx context.Context, g auth.Grant, column string) (any, error)

func (*CounterQuery) Sum

func (q *CounterQuery) Sum(ctx context.Context, g auth.Grant, column any) (any, error)

func (*CounterQuery) Touch

func (q *CounterQuery) Touch(ctx context.Context, g auth.Grant, column ...string) (int64, error)

func (*CounterQuery) Update

func (q *CounterQuery) Update(ctx context.Context, g auth.Grant, values map[string]any) (int64, error)

func (*CounterQuery) UpdateOrCreate

func (q *CounterQuery) UpdateOrCreate(ctx context.Context, g auth.Grant, attributes, values map[string]any) (*Counter, error)

func (*CounterQuery) Upsert

func (q *CounterQuery) Upsert(ctx context.Context, g auth.Grant, values []map[string]any, uniqueBy, update []string) (int64, error)

func (*CounterQuery) Value

func (q *CounterQuery) Value(ctx context.Context, g auth.Grant, column string) (any, error)

func (*CounterQuery) ValueOrFail

func (q *CounterQuery) ValueOrFail(ctx context.Context, g auth.Grant, column string) (any, error)

func (*CounterQuery) Where

func (q *CounterQuery) Where(column any, args ...any) *CounterQuery

Where adds a where clause; a func(*CounterQuery) in place of the column adds a group.

func (*CounterQuery) WhereBelongsTo

func (q *CounterQuery) WhereBelongsTo(relationshipName string, related ...model.Entity) *CounterQuery

func (*CounterQuery) WhereBetween

func (q *CounterQuery) WhereBetween(column any, from, to any) *CounterQuery

func (*CounterQuery) WhereColumn

func (q *CounterQuery) WhereColumn(first any, args ...any) *CounterQuery

func (*CounterQuery) WhereDoesntHave

func (q *CounterQuery) WhereDoesntHave(relation string, callback func(*query.Builder)) *CounterQuery

func (*CounterQuery) WhereExists

func (q *CounterQuery) WhereExists(callback func(*query.Builder)) *CounterQuery

func (*CounterQuery) WhereHas

func (q *CounterQuery) WhereHas(relation string, callback func(*query.Builder)) *CounterQuery

func (*CounterQuery) WhereHasCount

func (q *CounterQuery) WhereHasCount(relation string, callback func(*query.Builder), operator string, count int) *CounterQuery

func (*CounterQuery) WhereIn

func (q *CounterQuery) WhereIn(column any, values []any) *CounterQuery

func (*CounterQuery) WhereKey

func (q *CounterQuery) WhereKey(id any) *CounterQuery

func (*CounterQuery) WhereNot

func (q *CounterQuery) WhereNot(column any, args ...any) *CounterQuery

WhereNot adds a negated where clause, or a negated group.

func (*CounterQuery) WhereNotExists

func (q *CounterQuery) WhereNotExists(callback func(*query.Builder)) *CounterQuery

func (*CounterQuery) WhereNotIn

func (q *CounterQuery) WhereNotIn(column any, values []any) *CounterQuery

func (*CounterQuery) WhereNotNull

func (q *CounterQuery) WhereNotNull(columns ...any) *CounterQuery

func (*CounterQuery) WhereNull

func (q *CounterQuery) WhereNull(columns ...any) *CounterQuery

func (*CounterQuery) WhereRelation

func (q *CounterQuery) WhereRelation(relation string, column any, args ...any) *CounterQuery

func (*CounterQuery) With

func (q *CounterQuery) With(relations ...string) *CounterQuery

func (*CounterQuery) WithAvg

func (q *CounterQuery) WithAvg(relation, column string) *CounterQuery

func (*CounterQuery) WithCount

func (q *CounterQuery) WithCount(relations ...string) *CounterQuery

func (*CounterQuery) WithExists

func (q *CounterQuery) WithExists(relation string) *CounterQuery

func (*CounterQuery) WithMax

func (q *CounterQuery) WithMax(relation, column string) *CounterQuery

func (*CounterQuery) WithMin

func (q *CounterQuery) WithMin(relation, column string) *CounterQuery

func (*CounterQuery) WithSum

func (q *CounterQuery) WithSum(relation, column string) *CounterQuery

func (*CounterQuery) Without

func (q *CounterQuery) Without(relations ...string) *CounterQuery

func (*CounterQuery) WithoutGlobalScope

func (q *CounterQuery) WithoutGlobalScope(identifier string) *CounterQuery

func (*CounterQuery) WithoutGlobalScopes

func (q *CounterQuery) WithoutGlobalScopes(identifiers ...string) *CounterQuery

type Directory

type Directory interface {
	// Person returns a user of a tenant, or ErrNoAccount when there is none.
	Person(ctx context.Context, tenant, id string) (Person, error)
	// Team returns the members of the platform team a ticket can be assigned
	// to, in the order a list offers them.
	Team(ctx context.Context) ([]Person, error)
}

Directory names people. It is optional: without one a ticket keeps the name and the e-mail of its requester empty when the application did not supply them, a message shows its author's role instead of a name, and a ticket can be assigned only to the member of the team who is asking.

type Event

type Event struct {
	Kind EventKind
	// Ticket is the ticket after the change.
	Ticket Summary
	// Message is the message the event is about. It is never an internal one.
	Message MessageView
}

Event is what a Notifier is handed.

An internal note is never one: a note changes nothing the customer can read, and the package tells nobody about it.

type EventKind

type EventKind string

EventKind says what happened on a ticket that somebody should be told about.

const (
	// EventOpened is a ticket a customer opened, on the screens or through the
	// contact form. The team is the one to tell.
	EventOpened EventKind = "ticket.opened"
	// EventCustomerReplied is a reply from the customer. The team is the one to
	// tell.
	EventCustomerReplied EventKind = "ticket.customer_replied"
	// EventTeamReplied is an answer from the team. The customer is the one to
	// tell: the requester of the ticket.
	EventTeamReplied EventKind = "ticket.team_replied"
)

The events a Notifier is told about.

func (EventKind) ForTeam

func (k EventKind) ForTeam() bool

ForTeam says whether the team is the one to tell. When it is not, the requester of the ticket is.

type FileRow

type FileRow struct{ Name, Type, Size, URL string }

FileRow is one file of a message.

type Flag

type Flag bool

Flag is a yes-or-no a column holds as an integer, read back from whatever an engine answers with: the engines disagree about booleans and one place has to flatten them.

func (*Flag) Scan

func (f *Flag) Scan(value any) error

Scan reads the flag back. Anything it cannot read is refused rather than read as false.

func (Flag) Value

func (f Flag) Value() (driver.Value, error)

Value writes the flag as the integer the column holds.

type FormState

type FormState struct{ hview.Page }

FormState is what a form field reads from the page it is drawn on: what was typed before a refusal, and the message for each field.

func (FormState) ErrorSummary

func (f FormState) ErrorSummary() []string

ErrorSummary is one line per rejected field.

func (FormState) FieldError

func (f FormState) FieldError(name string) string

FieldError is the first message for one field, empty when it was accepted.

func (FormState) FieldErrors

func (f FormState) FieldErrors(name string) []string

FieldErrors is every message for one field.

func (FormState) HasErrors

func (f FormState) HasErrors() bool

HasErrors says whether the attempt that landed here was rejected.

func (FormState) LogValue

func (f FormState) LogValue() slog.Value

LogValue is the form state as a log line carries it, the token redacted.

func (FormState) MarshalJSON

func (f FormState) MarshalJSON() ([]byte, error)

MarshalJSON writes the form state with the CSRF token redacted.

type IndexPageData

type IndexPageData struct {
	Screen
	Rows          []TicketRow
	Query         string
	Statuses      []string
	SortKey       string
	SortDir       string
	URL, NewURL   string
	CanCreate     bool
	Pager         components.Paginator
	CurrentPage   int
	Pages, Total  int
	PerPage       int
	StatusOptions []components.SelectOption
}

IndexPageData is the customer's list, a page at a time, filtered, searched and ordered on the server.

type Labels

type Labels struct {
	Locale string
	// contains filtered or unexported fields
}

Labels are the sentences of this package in one locale.

func (Labels) Field

func (l Labels) Field(name string) string

Field is what a person reads in place of an input's name, in a summary of the rejected fields: "subject" reads "Assunto". A field with no line reads the way the input was named.

func (Labels) Origin

func (l Labels) Origin(o Origin) string

Origin is the label of where a ticket came from.

func (Labels) Priority

func (l Labels) Priority(p Priority) string

Priority is the label of a ticket's priority.

func (Labels) Role

func (l Labels) Role(r Role) string

Role is the label of what a message's author is to the ticket.

func (Labels) Status

func (l Labels) Status(s Status) string

Status is the label of a ticket's status.

func (Labels) Summary

func (l Labels) Summary(errs validation.Errors) []string

Summary is one line per rejected message, the field named before it, for the banner above a form. Nil when nothing was rejected, so a layout that ranges over it draws no banner.

func (Labels) T

func (l Labels) T(key string) string

T reads a key of this package: "screen.index_title".

type ListQuery

type ListQuery struct {
	Search   string
	Statuses []Status
	Sort     string
	Dir      string
	Page     int
	PerPage  int
}

ListQuery narrows the customer's list. Page counts from one.

type Message

type Message struct {
	model.Model

	// ID is the identifier. It is an ordered identifier, so messages written in
	// the same instant keep the order they were written in.
	ID string `db:"id"`

	// TenantID is the tenant of the ticket the message belongs to.
	TenantID string `db:"tenant_id"`

	// TicketID is the ticket.
	TicketID string `db:"ticket_id"`

	// AuthorID is who wrote it, empty for a line the system wrote.
	AuthorID string `db:"author_id"`

	// AuthorName is how the author was called when they wrote it.
	AuthorName string `db:"author_name"`

	// Role is what the author is to the ticket.
	Role Role `db:"role"`

	// Body is the text, plain with optional Markdown. The screens render it
	// through arandu-markdown, which sanitizes it.
	Body string `db:"body"`

	// Internal marks a note only the team reads. The customer is never shown a
	// message with this set, nor told one exists.
	Internal Flag `db:"internal"`

	// CreatedAt is when it was written, in UTC.
	CreatedAt time.Time `db:"created_at"`
}

Message is one entry of a ticket's conversation.

func (*Message) Fresh

func (row *Message) Fresh(ctx context.Context, g auth.Grant, with ...string) (*Message, error)

Fresh reads the row again, as a new Message; it shadows the untyped one the embedded model promotes.

func (*Message) Replicate

func (row *Message) Replicate(except ...string) (*Message, error)

Replicate returns an unsaved copy of the row without its key; it shadows the untyped one the embedded model promotes.

type MessageCollection

type MessageCollection []*Message

MessageCollection is rows of Message, as a query returns them.

func MessageCollectionOf

func MessageCollectionOf(rows model.Rows) MessageCollection

MessageCollectionOf converts rows of the table of Message. A nil input is a nil collection.

func (MessageCollection) Contains

func (c MessageCollection) Contains(key any) bool

func (MessageCollection) Except

func (c MessageCollection) Except(keys ...any) MessageCollection

func (MessageCollection) Find

func (c MessageCollection) Find(key any) *Message

func (MessageCollection) FindOrFail

func (c MessageCollection) FindOrFail(key any) (*Message, error)

func (MessageCollection) Fresh

func (MessageCollection) Load

func (c MessageCollection) Load(ctx context.Context, g auth.Grant, relations ...string) error

func (MessageCollection) LoadCount

func (c MessageCollection) LoadCount(ctx context.Context, g auth.Grant, relations ...string) error

func (MessageCollection) LoadMissing

func (c MessageCollection) LoadMissing(ctx context.Context, g auth.Grant, relations ...string) error

func (MessageCollection) ModelKeys

func (c MessageCollection) ModelKeys() []any

func (MessageCollection) Only

func (c MessageCollection) Only(keys ...any) MessageCollection

func (MessageCollection) Pluck

func (c MessageCollection) Pluck(column string) []any

func (MessageCollection) Rows

func (c MessageCollection) Rows() model.Rows

Rows converts the collection back to the core's rows.

func (MessageCollection) ToArray

func (c MessageCollection) ToArray() []map[string]any

func (MessageCollection) Unique

type MessageQuery

type MessageQuery struct {
	// contains filtered or unexported fields
}

MessageQuery is a query on the table of Message.

The methods that build return the query, so they chain. The methods that run take a context and an auth.Grant, and return Message rows or a value; there is no way to run one without a Grant.

func Messages

func Messages(db model.DB) *MessageQuery

Messages starts a query on the table of Message, through db.

Global scopes are on and, unless the table is Global, every statement is filtered by the tenant of the Grant its terminal takes.

func (*MessageQuery) AddSelect

func (q *MessageQuery) AddSelect(columns ...any) *MessageQuery

func (*MessageQuery) Avg

func (q *MessageQuery) Avg(ctx context.Context, g auth.Grant, column any) (any, error)

func (*MessageQuery) Base

func (q *MessageQuery) Base() *model.Builder

Base returns the core builder the query forwards to. It is for code in this package -- a local scope in the custom block of model.go -- and for the factory, which builds rows through it.

func (*MessageQuery) Chunk

func (q *MessageQuery) Chunk(ctx context.Context, g auth.Grant, count int, callback func(MessageCollection, int) (bool, error)) error

func (*MessageQuery) Clone

func (q *MessageQuery) Clone() *MessageQuery

Clone returns an independent copy of the query.

func (*MessageQuery) Count

func (q *MessageQuery) Count(ctx context.Context, g auth.Grant, columns ...any) (int64, error)

func (*MessageQuery) Create

func (q *MessageQuery) Create(ctx context.Context, g auth.Grant, attributes map[string]any) (*Message, error)

func (*MessageQuery) Cursor

func (q *MessageQuery) Cursor(ctx context.Context, g auth.Grant) iter.Seq2[*Message, error]

func (*MessageQuery) CursorPaginate

func (q *MessageQuery) CursorPaginate(ctx context.Context, g auth.Grant, perPage int, cursor *pagination.Cursor, opts pagination.Options, columns ...any) (MessageCollection, *pagination.CursorPage, error)

func (*MessageQuery) Decrement

func (q *MessageQuery) Decrement(ctx context.Context, g auth.Grant, column string, amount any, extra map[string]any) (int64, error)

func (*MessageQuery) Delete

func (q *MessageQuery) Delete(ctx context.Context, g auth.Grant) (int64, error)

func (*MessageQuery) DoesntExist

func (q *MessageQuery) DoesntExist(ctx context.Context, g auth.Grant) (bool, error)

func (*MessageQuery) DoesntHave

func (q *MessageQuery) DoesntHave(relation, boolean string, callback func(*query.Builder)) *MessageQuery

func (*MessageQuery) Each

func (q *MessageQuery) Each(ctx context.Context, g auth.Grant, count int, callback func(*Message, int) (bool, error)) error

func (*MessageQuery) Exists

func (q *MessageQuery) Exists(ctx context.Context, g auth.Grant) (bool, error)

func (*MessageQuery) Find

func (q *MessageQuery) Find(ctx context.Context, g auth.Grant, id any, columns ...any) (*Message, error)

func (*MessageQuery) FindMany

func (q *MessageQuery) FindMany(ctx context.Context, g auth.Grant, ids []any, columns ...any) (MessageCollection, error)

func (*MessageQuery) FindOrFail

func (q *MessageQuery) FindOrFail(ctx context.Context, g auth.Grant, id any, columns ...any) (*Message, error)

func (*MessageQuery) FindOrNew

func (q *MessageQuery) FindOrNew(ctx context.Context, g auth.Grant, id any, columns ...any) (*Message, error)

func (*MessageQuery) First

func (q *MessageQuery) First(ctx context.Context, g auth.Grant, columns ...any) (*Message, error)

First returns the first row, or nil and no error when there is none.

func (*MessageQuery) FirstOrCreate

func (q *MessageQuery) FirstOrCreate(ctx context.Context, g auth.Grant, attributes, values map[string]any) (*Message, error)

func (*MessageQuery) FirstOrFail

func (q *MessageQuery) FirstOrFail(ctx context.Context, g auth.Grant, columns ...any) (*Message, error)

func (*MessageQuery) FirstOrNew

func (q *MessageQuery) FirstOrNew(ctx context.Context, g auth.Grant, attributes, values map[string]any) (*Message, error)

func (*MessageQuery) FirstWhere

func (q *MessageQuery) FirstWhere(ctx context.Context, g auth.Grant, column any, args ...any) (*Message, error)

func (*MessageQuery) ForPage

func (q *MessageQuery) ForPage(page, perPage int) *MessageQuery

func (*MessageQuery) ForceCreate

func (q *MessageQuery) ForceCreate(ctx context.Context, g auth.Grant, attributes map[string]any) (*Message, error)

func (*MessageQuery) Get

func (q *MessageQuery) Get(ctx context.Context, g auth.Grant, columns ...any) (MessageCollection, error)

Get runs the query and returns the rows.

func (*MessageQuery) GroupBy

func (q *MessageQuery) GroupBy(groups ...any) *MessageQuery

func (*MessageQuery) Has

func (q *MessageQuery) Has(relation, operator string, count int, boolean string, callback func(*query.Builder)) *MessageQuery

func (*MessageQuery) Increment

func (q *MessageQuery) Increment(ctx context.Context, g auth.Grant, column string, amount any, extra map[string]any) (int64, error)

func (*MessageQuery) Insert

func (q *MessageQuery) Insert(ctx context.Context, g auth.Grant, values ...map[string]any) (bool, error)

func (*MessageQuery) InsertOrIgnore

func (q *MessageQuery) InsertOrIgnore(ctx context.Context, g auth.Grant, values ...map[string]any) (bool, error)

func (*MessageQuery) Join

func (q *MessageQuery) Join(table any, first any, args ...any) *MessageQuery

func (*MessageQuery) Latest

func (q *MessageQuery) Latest(column ...string) *MessageQuery

func (*MessageQuery) Limit

func (q *MessageQuery) Limit(value int) *MessageQuery

func (*MessageQuery) Max

func (q *MessageQuery) Max(ctx context.Context, g auth.Grant, column any) (any, error)

func (*MessageQuery) Min

func (q *MessageQuery) Min(ctx context.Context, g auth.Grant, column any) (any, error)

func (*MessageQuery) New

func (q *MessageQuery) New() (*Message, error)

New returns an empty Message on the query's connection, not yet saved, filled with what WithAttributes recorded on the query.

func (*MessageQuery) Offset

func (q *MessageQuery) Offset(value int) *MessageQuery

func (*MessageQuery) Oldest

func (q *MessageQuery) Oldest(column ...string) *MessageQuery

func (*MessageQuery) OrDoesntHave

func (q *MessageQuery) OrDoesntHave(relation string) *MessageQuery

func (*MessageQuery) OrHas

func (q *MessageQuery) OrHas(relation, operator string, count int) *MessageQuery

func (*MessageQuery) OrWhere

func (q *MessageQuery) OrWhere(column any, args ...any) *MessageQuery

OrWhere adds an or-where clause; a func(*MessageQuery) in place of the column adds a group.

func (*MessageQuery) OrWhereDoesntHave

func (q *MessageQuery) OrWhereDoesntHave(relation string, callback func(*query.Builder)) *MessageQuery

func (*MessageQuery) OrWhereHas

func (q *MessageQuery) OrWhereHas(relation string, callback func(*query.Builder)) *MessageQuery

func (*MessageQuery) OrWhereNot

func (q *MessageQuery) OrWhereNot(column any, args ...any) *MessageQuery

OrWhereNot adds an or-joined negated where clause, or a negated group.

func (*MessageQuery) OrWhereRelation

func (q *MessageQuery) OrWhereRelation(relation string, column any, args ...any) *MessageQuery

func (*MessageQuery) OrderBy

func (q *MessageQuery) OrderBy(column any, direction ...string) *MessageQuery

func (*MessageQuery) OrderByDesc

func (q *MessageQuery) OrderByDesc(column any) *MessageQuery

func (*MessageQuery) Paginate

func (q *MessageQuery) Paginate(ctx context.Context, g auth.Grant, perPage, page int, opts pagination.Options, columns ...any) (MessageCollection, *pagination.LengthAwarePage, error)

func (*MessageQuery) Pluck

func (q *MessageQuery) Pluck(ctx context.Context, g auth.Grant, column string) ([]any, error)

func (*MessageQuery) Select

func (q *MessageQuery) Select(columns ...any) *MessageQuery

func (*MessageQuery) SelectRaw

func (q *MessageQuery) SelectRaw(expression string, bindings ...any) *MessageQuery

func (*MessageQuery) SimplePaginate

func (q *MessageQuery) SimplePaginate(ctx context.Context, g auth.Grant, perPage, page int, opts pagination.Options, columns ...any) (MessageCollection, *pagination.Page, error)

func (*MessageQuery) Sole

func (q *MessageQuery) Sole(ctx context.Context, g auth.Grant, columns ...any) (*Message, error)

func (*MessageQuery) SoleValue

func (q *MessageQuery) SoleValue(ctx context.Context, g auth.Grant, column string) (any, error)

func (*MessageQuery) Sum

func (q *MessageQuery) Sum(ctx context.Context, g auth.Grant, column any) (any, error)

func (*MessageQuery) Touch

func (q *MessageQuery) Touch(ctx context.Context, g auth.Grant, column ...string) (int64, error)

func (*MessageQuery) Update

func (q *MessageQuery) Update(ctx context.Context, g auth.Grant, values map[string]any) (int64, error)

func (*MessageQuery) UpdateOrCreate

func (q *MessageQuery) UpdateOrCreate(ctx context.Context, g auth.Grant, attributes, values map[string]any) (*Message, error)

func (*MessageQuery) Upsert

func (q *MessageQuery) Upsert(ctx context.Context, g auth.Grant, values []map[string]any, uniqueBy, update []string) (int64, error)

func (*MessageQuery) Value

func (q *MessageQuery) Value(ctx context.Context, g auth.Grant, column string) (any, error)

func (*MessageQuery) ValueOrFail

func (q *MessageQuery) ValueOrFail(ctx context.Context, g auth.Grant, column string) (any, error)

func (*MessageQuery) Where

func (q *MessageQuery) Where(column any, args ...any) *MessageQuery

Where adds a where clause; a func(*MessageQuery) in place of the column adds a group.

func (*MessageQuery) WhereBelongsTo

func (q *MessageQuery) WhereBelongsTo(relationshipName string, related ...model.Entity) *MessageQuery

func (*MessageQuery) WhereBetween

func (q *MessageQuery) WhereBetween(column any, from, to any) *MessageQuery

func (*MessageQuery) WhereColumn

func (q *MessageQuery) WhereColumn(first any, args ...any) *MessageQuery

func (*MessageQuery) WhereDoesntHave

func (q *MessageQuery) WhereDoesntHave(relation string, callback func(*query.Builder)) *MessageQuery

func (*MessageQuery) WhereExists

func (q *MessageQuery) WhereExists(callback func(*query.Builder)) *MessageQuery

func (*MessageQuery) WhereHas

func (q *MessageQuery) WhereHas(relation string, callback func(*query.Builder)) *MessageQuery

func (*MessageQuery) WhereHasCount

func (q *MessageQuery) WhereHasCount(relation string, callback func(*query.Builder), operator string, count int) *MessageQuery

func (*MessageQuery) WhereIn

func (q *MessageQuery) WhereIn(column any, values []any) *MessageQuery

func (*MessageQuery) WhereKey

func (q *MessageQuery) WhereKey(id any) *MessageQuery

func (*MessageQuery) WhereNot

func (q *MessageQuery) WhereNot(column any, args ...any) *MessageQuery

WhereNot adds a negated where clause, or a negated group.

func (*MessageQuery) WhereNotExists

func (q *MessageQuery) WhereNotExists(callback func(*query.Builder)) *MessageQuery

func (*MessageQuery) WhereNotIn

func (q *MessageQuery) WhereNotIn(column any, values []any) *MessageQuery

func (*MessageQuery) WhereNotNull

func (q *MessageQuery) WhereNotNull(columns ...any) *MessageQuery

func (*MessageQuery) WhereNull

func (q *MessageQuery) WhereNull(columns ...any) *MessageQuery

func (*MessageQuery) WhereRelation

func (q *MessageQuery) WhereRelation(relation string, column any, args ...any) *MessageQuery

func (*MessageQuery) With

func (q *MessageQuery) With(relations ...string) *MessageQuery

func (*MessageQuery) WithAvg

func (q *MessageQuery) WithAvg(relation, column string) *MessageQuery

func (*MessageQuery) WithCount

func (q *MessageQuery) WithCount(relations ...string) *MessageQuery

func (*MessageQuery) WithExists

func (q *MessageQuery) WithExists(relation string) *MessageQuery

func (*MessageQuery) WithMax

func (q *MessageQuery) WithMax(relation, column string) *MessageQuery

func (*MessageQuery) WithMin

func (q *MessageQuery) WithMin(relation, column string) *MessageQuery

func (*MessageQuery) WithSum

func (q *MessageQuery) WithSum(relation, column string) *MessageQuery

func (*MessageQuery) Without

func (q *MessageQuery) Without(relations ...string) *MessageQuery

func (*MessageQuery) WithoutGlobalScope

func (q *MessageQuery) WithoutGlobalScope(identifier string) *MessageQuery

func (*MessageQuery) WithoutGlobalScopes

func (q *MessageQuery) WithoutGlobalScopes(identifiers ...string) *MessageQuery

type MessageRow

type MessageRow struct {
	ID, Author, Role, RoleKey string
	When, WhenISO             string
	// Internal marks a note only the team reads; the customer's rows hold none.
	Internal bool
	// Body is the text as sanitized HTML: Config.Markdown's output, or the text
	// escaped. It is the one value on a page written without escaping, and
	// nothing reaches it that did not pass through one of those two.
	Body  template.HTML
	Files []FileRow
}

MessageRow is one message of a conversation.

type MessageView

type MessageView struct {
	ID          string
	AuthorID    string
	AuthorName  string
	Role        Role
	Body        string
	Internal    bool
	CreatedAt   time.Time
	Attachments []AttachmentView
}

MessageView is a message as a screen, a response or a notification carries it.

type Module

type Module struct {
	// contains filtered or unexported fields
}

Module is what the application registers.

It implements foundation.Module, which is Name and Routes and nothing else -- that pair is the whole public contract between a package and the framework.

It also implements foundation.Migratable, because it owns tables, and foundation.Publishable, because it hands view sources to the project. The other optional interfaces are declared beside Module in the framework and are opted into the same way, by implementing them: Bootable to prepare state at boot, Background to run a loop of its own, Schedulable to declare work for the scheduler, Health to report on the storage it depends on, Closable to give resources back at shutdown.

func New

func New(cfg Config, db *data.DB, sessions *security.SessionStore) (*Module, error)

New returns the module, or the reason it cannot be built.

The collaborators are parameters and not fields somebody fills in afterwards: a module that could be registered half-wired is a module whose first request is the thing that reports the missing half.

It returns an error rather than panicking or carrying on, because everything it refuses is a wiring mistake, and a wiring mistake found at boot costs one restart. The same mistake found later is a request that reached a nil handle.

func (*Module) Boot

func (m *Module) Boot(context.Context) error

Boot refuses to serve when a view this package renders is not in the binary.

A compiled view registers itself from init(), so by the time anything boots the question has one answer already: either the application published the files, compiled them and imported the package they became, or it did not. Asking here turns "did anybody run the install command" into one refusal at start-up that names the views and the command, instead of a 500 on the first request that reached one of them -- which is where it used to be answered, once per page, to whoever happened to open it.

It also holds the destination. Every file the archive offers has to land under the module directory named after this module: an archive that reached resources/views/home.kyse.go would land on a page the application wrote, and what publishes the files writes what the archive says.

func (*Module) CustomerRoutes

func (m *Module) CustomerRoutes(r *fhttp.Router)

CustomerRoutes registers the customer's routes alone, under Prefix, for an application that mounts them inside its own side -- a customer's panel -- with that side's guards on r.

func (*Module) Labels

func (m *Module) Labels() Labels

Labels answers the sentences of the module's locale.

func (*Module) Migrations

func (m *Module) Migrations() []foundation.Migration

Migrations declares the schema this module owns.

They are returned in the order their names sort in, which is the order they apply in: the name carries the order, and nothing else decides it.

func (*Module) Name

func (m *Module) Name() string

Name is the module identifier: a lowercase slug, stable, no spaces.

It is what `aru route:list` groups by and what the route names are prefixed with, so changing it changes addresses that other code has already written down.

func (*Module) Publishes

func (m *Module) Publishes() []foundation.Publication

Publishes declares the files this package offers, each at the path it takes relative to the root of the project.

One tree, under one tag. A publication may carry a page, a component, a configuration file, a migration, a catalogue of sentences or an asset, and this package offers the first of those and nothing else. Each absence is a decision rather than an omission:

  • configuration is the Config struct New is handed, checked by the compiler and validated before the module exists. A file copied into the project beside it would be a second place to say the same thing, and only one of the two could be the one the code reads.
  • a stylesheet, a script or any other asset is registered with the view layer and served from an address derived from its own bytes. Copying one into the project would put a second copy of those bytes under a second address, and a page can only reference one of them.
  • translations are overridden by writing the lines the application wants into its own override tree, which the catalogue loader already reads. A copy of every line this package ships is a copy that goes stale, and it goes stale without saying so.
  • migrations are declared and collected, never copied. A copy in the project's own migration directory is found by the runner as well, so one schema change applies twice under two names.

What is left is the markup, and it is here for the reason the others are not: it is the one thing the project is expected to edit. A package cannot know what a screen should say in a product it has never seen.

func (*Module) Routes

func (m *Module) Routes(r *fhttp.Router)

Routes registers every route of the module: the customer's and the team's.

An application that keeps the two sides apart -- and the team's session is valid on its own side only -- registers them separately with CustomerRoutes and TeamRoutes, each on the router group its own guards are on.

Every route needs somebody signed in, so the guard is mounted once per side, on the router the routes are registered on, rather than route by route: r is replaced by the guarded group before the first registration, and no unguarded router is left in scope for the next route to be added to. RequireAuth sends a request with no session to the sign-in screen, and puts the subject of one that has a session on its context, where a handler reads it with ctx.User; Config.Middleware runs after it, and Config.TeamMiddleware after that on the team's routes.

func (*Module) Team

func (m *Module) Team() *TeamService

Team is the team's use cases, for an application that calls them from its own code. Every method asks TeamPolicy, so handing it out opens nothing.

func (*Module) TeamRoutes

func (m *Module) TeamRoutes(r *fhttp.Router)

TeamRoutes registers the team's routes alone, under TeamPrefix, for an application that mounts them inside its operator console with that console's guards on r.

func (*Module) Tickets

func (m *Module) Tickets() *TicketService

Tickets is the customer's use cases, for an application that calls them from its own code: the contact form, a command. Every method asks the policy, so handing it out opens nothing.

func (*Module) URLs

func (m *Module) URLs() URLs

URLs are the addresses of the routes, for an application that links to them: a menu, an e-mail.

type NewPageData

type NewPageData struct {
	Screen
	Action, BackURL string
	// Files says the form takes attachments, and the rest says which.
	Files    bool
	MaxFiles int
	MaxMB    string
	Accept   string
}

NewPageData is the form that opens a ticket.

type Notifier

type Notifier interface {
	Notify(ctx context.Context, e Event) error
}

Notifier tells people that something happened on a ticket.

The application implements it with its own mailer: this package sends no e-mail. Notify runs after the change is stored, and its error does not undo the change: the ticket is already open and the reply already written, and a mail server that is down is no reason for a customer to write it twice. The error is logged and the request goes on. An application that must not lose a notification enqueues it here and delivers it from its queue.

type OpenRequest

type OpenRequest struct {
	Subject  string
	Category string
	Body     string
}

OpenRequest is what a customer asks for when opening a ticket on the screens. There is no tenant, no requester and no priority here and there must never be one: the tenant and the requester come from the Grant and the subject, and the priority is the team's to set.

type Origin

type Origin string

Origin is where a ticket came from.

const (
	// OriginWeb is a ticket the customer opened on the screens of this package.
	OriginWeb Origin = "web"
	// OriginContactForm is a ticket OpenFromContact opened from the
	// application's public contact form.
	OriginContactForm Origin = "contact_form"
	// OriginEmail is a ticket the application opened from an incoming e-mail.
	OriginEmail Origin = "email"
)

The origins of a ticket.

func (Origin) Valid

func (o Origin) Valid() bool

Valid reports whether the origin is one of the three.

type Page

type Page struct {
	Rows  []Summary
	Total int
	Page  int
	Pages int
}

Page is one page of a list, and how many rows the whole list has.

type Paths

type Paths struct {
	New, Replies, Closure, Reopening, Attachments string
	Notes, Status, Priority, Assignee             string
}

Paths are the segments the routes of this package are built from. Each one starts with "/" and holds no wildcard.

GET  Prefix                                       the customer's tickets
GET  Prefix + New                                 the form that opens one
POST Prefix                                       open one
GET  Prefix + /{id}                               one conversation
POST Prefix + /{id} + Replies                     reply
POST Prefix + /{id} + Closure                     close
POST Prefix + /{id} + Reopening                   reopen
GET  Prefix + /{id} + Attachments + /{attachment} a file

and under TeamPrefix the list, one conversation, Replies, Notes, Status, Priority, Assignee and Attachments.

type Person

type Person struct {
	ID    string
	Name  string
	Email string
}

Person is somebody the application knows, as the screens name them.

type Priority

type Priority string

Priority is how soon the team looks at a ticket.

const (
	PriorityLow    Priority = "low"
	PriorityNormal Priority = "normal"
	PriorityHigh   Priority = "high"
	PriorityUrgent Priority = "urgent"
)

The priorities of a ticket.

func (Priority) Valid

func (p Priority) Valid() bool

Valid reports whether the priority is one of the four.

type ReplyRequest

type ReplyRequest struct {
	Body string
}

ReplyRequest is a message added to a ticket.

type Role

type Role string

Role is what an author of a message is to the ticket.

const (
	// RoleCustomer is the person the ticket belongs to.
	RoleCustomer Role = "customer"
	// RoleTeam is a member of the platform team.
	RoleTeam Role = "team"
	// RoleSystem is the package itself, for a line nobody wrote.
	RoleSystem Role = "system"
)

The roles of a message's author.

type Screen

type Screen struct {
	hview.Page
	Labels Labels
	// URLs are the addresses of the package's routes.
	URLs URLs
	// Location is the timezone instants are written in.
	Location *time.Location
	// contains filtered or unexported fields
}

Screen is what every page of this package carries: the page hesape's view.New builds, the application's chrome, and the sentences.

It embeds hesape's view.Page, so every page of this package is a hesape/view.Layout -- what the layouts of an application on Arandu framework v0.62 and newer assert -- and it answers HasErrors and ErrorSummary as well, so it is also the framework/view.Layout an older layout asks for.

func (Screen) All

func (s Screen) All() []string

All is one line per rejected message, for the banner above a form, in the screen's language: the field is named by its label in the catalogue ("Assunto: Preencha este campo.").

func (Screen) Chrome

func (s Screen) Chrome() any

Chrome is what Config.Chrome answered for this screen: the application's sidebar, navigation and account menu, in whatever type its layout draws from. A layout asserts that type on it; nil when Config.Chrome is nil.

func (Screen) ErrorSummary

func (s Screen) ErrorSummary() []string

ErrorSummary is one line per rejected field: framework/view.Layout's name for All.

func (Screen) FieldErrors

func (s Screen) FieldErrors(name string) []string

FieldErrors is every message for one field.

func (Screen) FilesHint

func (s Screen) FilesHint(max int, mb string) string

FilesHint is the sentence under a file input: what may be sent, and how much.

func (Screen) Form

func (s Screen) Form() FormState

Form is the form state of the page.

func (Screen) HasErrors

func (s Screen) HasErrors() bool

HasErrors says whether the attempt that landed here was rejected: framework/view.Layout's name for hesape's Any.

func (Screen) LogValue

func (s Screen) LogValue() slog.Value

LogValue is the page as a log line carries it, the token redacted.

func (Screen) MarshalJSON

func (s Screen) MarshalJSON() ([]byte, error)

MarshalJSON writes the page with the CSRF token redacted, as framework/view.Page does: a page dumped for debugging never publishes the token. It answers for every page data that embeds Screen.

func (Screen) PriorityOptions

func (s Screen) PriorityOptions() []components.SelectOption

PriorityOptions are the priorities as options of a filter or a select.

func (Screen) SearchLabel

func (s Screen) SearchLabel(table string) string

SearchLabel is the name a screen reader gives a table's search box: "Buscar em Chamados".

func (Screen) StatusOptions

func (s Screen) StatusOptions() []components.SelectOption

StatusOptions are the statuses as options of a filter or a select.

func (Screen) TableLines

func (s Screen) TableLines() components.DataTableLines

TableLines are the sentences a data table on these screens says.

func (Screen) When

func (s Screen) When(t time.Time) string

When is an instant as Brazilians write it, in the screen's timezone.

type ShowPageData

type ShowPageData struct {
	Screen
	Ticket                        TicketRow
	Messages                      []MessageRow
	BackURL                       string
	ReplyURL, CloseURL, ReopenURL string
	CanReply, CanClose            bool
	CanReopen                     bool
	Files                         bool
	MaxFiles                      int
	MaxMB                         string
	Accept                        string
}

ShowPageData is one conversation, for the customer.

type Status

type Status string

Status is where a ticket stands.

const (
	// StatusOpen is a ticket nobody on the team has answered yet.
	StatusOpen Status = "open"
	// StatusWaitingCustomer is a ticket the team answered and the customer owes
	// a reply to.
	StatusWaitingCustomer Status = "waiting_customer"
	// StatusWaitingTeam is a ticket the customer answered and the team owes a
	// reply to.
	StatusWaitingTeam Status = "waiting_team"
	// StatusResolved is a ticket the team considers answered. A reply from the
	// customer brings it back.
	StatusResolved Status = "resolved"
	// StatusClosed is a ticket nobody replies to any more, until it is
	// reopened.
	StatusClosed Status = "closed"
)

The statuses of a ticket.

func (Status) Valid

func (s Status) Valid() bool

Valid reports whether the status is one of the five.

type Summary

type Summary struct {
	ID             string
	Tenant         string
	Number         int64
	Subject        string
	Status         Status
	Priority       Priority
	Category       string
	RequesterID    string
	RequesterName  string
	RequesterEmail string
	AssigneeID     string
	AssigneeName   string
	Origin         Origin
	LastMessageAt  time.Time
	CreatedAt      time.Time
	UpdatedAt      time.Time
	ClosedAt       *time.Time
}

Summary is a ticket as a list, a response or a notification carries it: plain values, with no connection to the database and nothing the customer must not read.

func (Summary) Code

func (s Summary) Code() string

Code is the number as people say it: "#000123".

type TeamIndexPageData

type TeamIndexPageData struct {
	Screen
	Rows            []TicketRow
	Query           string
	Statuses        []string
	Priorities      []string
	Assignee        string
	Space           string
	SortKey         string
	SortDir         string
	URL             string
	Pager           components.Paginator
	CurrentPage     int
	Pages, Total    int
	StatusOptions   []components.SelectOption
	PriorityOptions []components.SelectOption
	AssigneeOptions []components.SelectOption
}

TeamIndexPageData is the team's list, a page at a time, filtered, searched and ordered on the server.

type TeamPolicy

type TeamPolicy struct {
	// Realm is the tenant the team signs in to. Empty refuses everybody.
	Realm string
}

TeamPolicy is the only authority over what the platform team does with a Ticket of any space.

It denies unless the subject is a member of the team -- a subject of Realm, the tenant the team signs in to -- who was granted the action. A subject of any other tenant is refused whatever actions it carries, and so is a subject somebody else is viewing as: an operator inside a customer's account acts as the customer and reads no other space.

It is the one policy that does not compare a record's tenant with the subject's, because crossing tenants is what the team does. The tenant of the work itself is not the subject's: the service reads the ticket to find it, and then acts in that tenant under a Grant scoped to it, issued only after this policy allowed the action.

func (TeamPolicy) Can

Can decides whether the subject may perform the action.

func (TeamPolicy) Writes

func (TeamPolicy) Writes(a security.Action) bool

Writes reports whether a Grant issued for the action may change anything.

type TeamQuery

type TeamQuery struct {
	// Search matches the number, the subject and the requester's name and
	// e-mail.
	Search     string
	Statuses   []Status
	Priorities []Priority
	// Assignee is a team member's identifier, AssigneeNone for the tickets
	// nobody took, or empty for every ticket.
	Assignee string
	// Tenant narrows the list to one space.
	Tenant  string
	Sort    string
	Dir     string
	Page    int
	PerPage int
}

TeamQuery narrows the team's list. Page counts from one.

type TeamService

type TeamService struct {
	// contains filtered or unexported fields
}

TeamService orchestrates what the platform team does with the tickets of every space.

A member of the team is a subject of Config.TeamTenant, and TeamPolicy decides what they may do. The tickets are in the customers' tenants, so a use case here goes in two steps: it asks the policy for a Grant in the team's own realm, reads the list or finds the ticket's tenant with it, and then acts with a Grant scoped to that one tenant, which exists only because the first one was issued for the same action. Nothing a request names chooses the tenant: it is the one the ticket's row says.

func NewTeamService

func NewTeamService(db *data.DB, cfg Config) *TeamService

NewTeamService wires the service over the application's database handle.

func (*TeamService) Assign

func (s *TeamService) Assign(ctx context.Context, actor security.Subject, id, assigneeID string) (*Ticket, error)

Assign gives a ticket to a member of the team, and an empty identifier takes it back. With a Directory the member has to be on its list; without one a member can only take a ticket themselves.

func (*TeamService) Attachment

func (s *TeamService) Attachment(ctx context.Context, actor security.Subject, ticketID, attachmentID string) (*Attachment, filesystem.File, error)

Attachment opens a file of any message of a ticket, the team's notes included. The caller closes the body. Reading it is recorded.

func (*TeamService) Find

func (s *TeamService) Find(ctx context.Context, actor security.Subject, id string) (Conversation, error)

Find returns one conversation with its internal notes: the team reads it all.

func (*TeamService) List

func (s *TeamService) List(ctx context.Context, actor security.Subject, q TeamQuery) (Page, error)

List returns a page of the tickets of every space: searched by number, subject and requester, filtered by status, priority, assignee and space, and ordered by the server.

func (*TeamService) Note

func (s *TeamService) Note(ctx context.Context, actor security.Subject, id string, in ReplyRequest, uploads []Upload) (*Message, error)

Note writes a note only the team reads. It changes neither the status nor the moment of the last message, tells nobody, and its entry in the log says that a note was written and not what it says.

func (*TeamService) Reply

func (s *TeamService) Reply(ctx context.Context, actor security.Subject, id string, in ReplyRequest, uploads []Upload) (*Message, error)

Reply answers the customer. The ticket waits for them, and they are told. A closed ticket takes no reply.

func (*TeamService) SetPriority

func (s *TeamService) SetPriority(ctx context.Context, actor security.Subject, id string, next Priority) (*Ticket, error)

SetPriority changes how soon the team looks at a ticket.

func (*TeamService) SetStatus

func (s *TeamService) SetStatus(ctx context.Context, actor security.Subject, id string, next Status) (*Ticket, error)

SetStatus moves a ticket to the status the team chose. Closing it and taking it out of closed have entries of their own in the log.

func (*TeamService) Team

func (s *TeamService) Team(ctx context.Context, actor security.Subject) ([]Person, error)

Team returns the members of the team a ticket can be assigned to, as the application's Directory lists them. Without a Directory it is empty.

type TeamShowPageData

type TeamShowPageData struct {
	Screen
	Ticket          TicketRow
	Messages        []MessageRow
	BackURL         string
	ReplyURL        string
	NoteURL         string
	StatusURL       string
	PriorityURL     string
	AssignURL       string
	CanReply        bool
	CanNote         bool
	CanManage       bool
	CanAssign       bool
	StatusOptions   []components.SelectOption
	PriorityOptions []components.SelectOption
	AssigneeOptions []components.SelectOption
	Files           bool
	MaxFiles        int
	MaxMB           string
	Accept          string
}

TeamShowPageData is one conversation, for the team, with its internal notes and the controls that change the ticket.

type Ticket

type Ticket struct {
	model.Model

	// ID is the identifier. It is generated by the application rather than by
	// the database, because a DEFAULT that produces a uuid is spelled
	// differently in every engine.
	ID string `db:"id"`

	// TenantID is the space the ticket belongs to. It is written from the Grant
	// and read back so the policy can compare it against the subject's tenant.
	TenantID string `db:"tenant_id"`

	// Number is the ticket's sequence in its tenant, from one. People say it as
	// Code: "#000123".
	Number int64 `db:"number"`

	// Subject is the one line the customer gave the request.
	Subject string `db:"subject"`

	// Status is where the ticket stands.
	Status Status `db:"status"`

	// Priority is how soon the team looks at it. Only the team changes it.
	Priority Priority `db:"priority"`

	// Category is a short free text, or empty.
	Category string `db:"category"`

	// RequesterID is the user of the tenant who asked. The application owns
	// what a user is; this package only compares the identifier with the
	// subject's.
	RequesterID string `db:"requester_id"`

	// RequesterName and RequesterEmail are who the requester was when the
	// ticket was opened, as the application's Directory or AccountResolver
	// answered. They are a copy: the team searches by them and reads them
	// without asking the application again.
	RequesterName  string `db:"requester_name"`
	RequesterEmail string `db:"requester_email"`

	// AssigneeID is the team member answering, or empty. The team lives in the
	// platform's own tenant, so the identifier is a user of that tenant and the
	// name is a copy of how the team knew them when they took the ticket.
	AssigneeID   string `db:"assignee_id"`
	AssigneeName string `db:"assignee_name"`

	// Origin is where the ticket came from.
	Origin Origin `db:"origin"`

	// OriginKey identifies a ticket opened from a contact form by what it said,
	// so the same form sent twice opens it once. Empty for every other origin.
	OriginKey string `db:"origin_key"`

	// LastMessageAt is when the last message the customer can read was written.
	LastMessageAt time.Time `db:"last_message_at"`

	// CreatedAt is when the row was written, in UTC.
	CreatedAt time.Time `db:"created_at"`

	// UpdatedAt is when the row last changed, in UTC.
	UpdatedAt time.Time `db:"updated_at"`

	// ClosedAt is when the ticket was closed, nil while it is not.
	ClosedAt *time.Time `db:"closed_at"`
}

Ticket is one request for support: a conversation between a customer of a tenant and the platform team.

It embeds the model, so a row returned by a query carries the connection and can be saved again. Build a new row with Tickets(db).New(): a struct literal has no connection and its write methods return model.ErrUnwired.

Tickets, TicketQuery and TicketCollection are generated beside this file, in TicketQuery.go, by aru model:build.

func (*Ticket) AddNote

func (t *Ticket) AddNote(now time.Time) error

AddNote stamps a note only the team reads. A note moves nothing the customer can read: the status stays, and so does the moment of the last message.

func (*Ticket) Assign

func (t *Ticket) Assign(id, name string, now time.Time) error

Assign gives the ticket to a team member. An empty id takes it back.

func (Ticket) CanClose

func (t Ticket) CanClose() bool

CanClose reports whether the ticket can be closed.

func (Ticket) CanReopen

func (t Ticket) CanReopen() bool

CanReopen reports whether the ticket can be reopened: it is resolved or closed.

func (Ticket) CanReply

func (t Ticket) CanReply() bool

CanReply reports whether a message can still be added: a closed ticket is reopened first.

func (*Ticket) Close

func (t *Ticket) Close(now time.Time) error

Close ends the conversation.

func (Ticket) Code

func (t Ticket) Code() string

Code is the number as people say it: "#000123".

func (*Ticket) Fresh

func (row *Ticket) Fresh(ctx context.Context, g auth.Grant, with ...string) (*Ticket, error)

Fresh reads the row again, as a new Ticket; it shadows the untyped one the embedded model promotes.

func (Ticket) IsClosed

func (t Ticket) IsClosed() bool

IsClosed reports whether nobody replies to the ticket any more.

func (*Ticket) ReceiveCustomerReply

func (t *Ticket) ReceiveCustomerReply(now time.Time) error

ReceiveCustomerReply moves the ticket the way a reply from the customer does: a ticket nobody has answered stays open, any other becomes the team's to answer. A closed ticket refuses it.

func (*Ticket) ReceiveTeamReply

func (t *Ticket) ReceiveTeamReply(now time.Time) error

ReceiveTeamReply moves the ticket the way an answer from the team does: it becomes the customer's to answer. A closed ticket refuses it.

func (*Ticket) Reopen

func (t *Ticket) Reopen(now time.Time) error

Reopen brings a resolved or closed ticket back to the team.

func (*Ticket) Replicate

func (row *Ticket) Replicate(except ...string) (*Ticket, error)

Replicate returns an unsaved copy of the row without its key; it shadows the untyped one the embedded model promotes.

func (*Ticket) SetPriority

func (t *Ticket) SetPriority(next Priority, now time.Time) error

SetPriority changes how soon the team looks at the ticket.

func (*Ticket) SetStatus

func (t *Ticket) SetStatus(next Status, now time.Time) error

SetStatus moves the ticket to the status the team chose.

type TicketCollection

type TicketCollection []*Ticket

TicketCollection is rows of Ticket, as a query returns them.

func TicketCollectionOf

func TicketCollectionOf(rows model.Rows) TicketCollection

TicketCollectionOf converts rows of the table of Ticket. A nil input is a nil collection.

func (TicketCollection) Contains

func (c TicketCollection) Contains(key any) bool

func (TicketCollection) Except

func (c TicketCollection) Except(keys ...any) TicketCollection

func (TicketCollection) Find

func (c TicketCollection) Find(key any) *Ticket

func (TicketCollection) FindOrFail

func (c TicketCollection) FindOrFail(key any) (*Ticket, error)

func (TicketCollection) Fresh

func (c TicketCollection) Fresh(ctx context.Context, g auth.Grant, with ...string) (TicketCollection, error)

func (TicketCollection) Load

func (c TicketCollection) Load(ctx context.Context, g auth.Grant, relations ...string) error

func (TicketCollection) LoadCount

func (c TicketCollection) LoadCount(ctx context.Context, g auth.Grant, relations ...string) error

func (TicketCollection) LoadMissing

func (c TicketCollection) LoadMissing(ctx context.Context, g auth.Grant, relations ...string) error

func (TicketCollection) ModelKeys

func (c TicketCollection) ModelKeys() []any

func (TicketCollection) Only

func (c TicketCollection) Only(keys ...any) TicketCollection

func (TicketCollection) Pluck

func (c TicketCollection) Pluck(column string) []any

func (TicketCollection) Rows

func (c TicketCollection) Rows() model.Rows

Rows converts the collection back to the core's rows.

func (TicketCollection) ToArray

func (c TicketCollection) ToArray() []map[string]any

func (TicketCollection) Unique

type TicketPolicy

type TicketPolicy struct{}

TicketPolicy is the only authority over what a customer does with a Ticket.

It denies unless a rule below says otherwise, and every rule reads what the subject was granted: a subject with no actions, an administrator included, is refused everything. Ownership decides the rest -- a customer reads and answers the tickets they asked for, and the whole space's only with TicketViewSpace -- and the tenant of the record is checked first, before any rule, so a rule that opens an action opens it inside one space only.

func (TicketPolicy) Can

Can decides whether the subject may perform the action on the record.

It is the only place that decides. The service reaches the Model only after Authorize turns this method's nil result into a Grant.

The record is the empty Ticket where the question is "may this subject do this kind of thing at all", and the loaded row where it is "may they do it to this ticket". Both are asked, in that order, and the second is what a rule about ownership answers.

func (TicketPolicy) Writes

func (TicketPolicy) Writes(a security.Action) bool

Writes reports whether a Grant issued for the action may change anything.

type TicketQuery

type TicketQuery struct {
	// contains filtered or unexported fields
}

TicketQuery is a query on the table of Ticket.

The methods that build return the query, so they chain. The methods that run take a context and an auth.Grant, and return Ticket rows or a value; there is no way to run one without a Grant.

func Tickets

func Tickets(db model.DB) *TicketQuery

Tickets starts a query on the table of Ticket, through db.

Global scopes are on and, unless the table is Global, every statement is filtered by the tenant of the Grant its terminal takes.

func (*TicketQuery) AddSelect

func (q *TicketQuery) AddSelect(columns ...any) *TicketQuery

func (*TicketQuery) Avg

func (q *TicketQuery) Avg(ctx context.Context, g auth.Grant, column any) (any, error)

func (*TicketQuery) Base

func (q *TicketQuery) Base() *model.Builder

Base returns the core builder the query forwards to. It is for code in this package -- a local scope in the custom block of model.go -- and for the factory, which builds rows through it.

func (*TicketQuery) Chunk

func (q *TicketQuery) Chunk(ctx context.Context, g auth.Grant, count int, callback func(TicketCollection, int) (bool, error)) error

func (*TicketQuery) Clone

func (q *TicketQuery) Clone() *TicketQuery

Clone returns an independent copy of the query.

func (*TicketQuery) Count

func (q *TicketQuery) Count(ctx context.Context, g auth.Grant, columns ...any) (int64, error)

func (*TicketQuery) Create

func (q *TicketQuery) Create(ctx context.Context, g auth.Grant, attributes map[string]any) (*Ticket, error)

func (*TicketQuery) Cursor

func (q *TicketQuery) Cursor(ctx context.Context, g auth.Grant) iter.Seq2[*Ticket, error]

func (*TicketQuery) CursorPaginate

func (q *TicketQuery) CursorPaginate(ctx context.Context, g auth.Grant, perPage int, cursor *pagination.Cursor, opts pagination.Options, columns ...any) (TicketCollection, *pagination.CursorPage, error)

func (*TicketQuery) Decrement

func (q *TicketQuery) Decrement(ctx context.Context, g auth.Grant, column string, amount any, extra map[string]any) (int64, error)

func (*TicketQuery) Delete

func (q *TicketQuery) Delete(ctx context.Context, g auth.Grant) (int64, error)

func (*TicketQuery) DoesntExist

func (q *TicketQuery) DoesntExist(ctx context.Context, g auth.Grant) (bool, error)

func (*TicketQuery) DoesntHave

func (q *TicketQuery) DoesntHave(relation, boolean string, callback func(*query.Builder)) *TicketQuery

func (*TicketQuery) Each

func (q *TicketQuery) Each(ctx context.Context, g auth.Grant, count int, callback func(*Ticket, int) (bool, error)) error

func (*TicketQuery) Exists

func (q *TicketQuery) Exists(ctx context.Context, g auth.Grant) (bool, error)

func (*TicketQuery) Find

func (q *TicketQuery) Find(ctx context.Context, g auth.Grant, id any, columns ...any) (*Ticket, error)

func (*TicketQuery) FindMany

func (q *TicketQuery) FindMany(ctx context.Context, g auth.Grant, ids []any, columns ...any) (TicketCollection, error)

func (*TicketQuery) FindOrFail

func (q *TicketQuery) FindOrFail(ctx context.Context, g auth.Grant, id any, columns ...any) (*Ticket, error)

func (*TicketQuery) FindOrNew

func (q *TicketQuery) FindOrNew(ctx context.Context, g auth.Grant, id any, columns ...any) (*Ticket, error)

func (*TicketQuery) First

func (q *TicketQuery) First(ctx context.Context, g auth.Grant, columns ...any) (*Ticket, error)

First returns the first row, or nil and no error when there is none.

func (*TicketQuery) FirstOrCreate

func (q *TicketQuery) FirstOrCreate(ctx context.Context, g auth.Grant, attributes, values map[string]any) (*Ticket, error)

func (*TicketQuery) FirstOrFail

func (q *TicketQuery) FirstOrFail(ctx context.Context, g auth.Grant, columns ...any) (*Ticket, error)

func (*TicketQuery) FirstOrNew

func (q *TicketQuery) FirstOrNew(ctx context.Context, g auth.Grant, attributes, values map[string]any) (*Ticket, error)

func (*TicketQuery) FirstWhere

func (q *TicketQuery) FirstWhere(ctx context.Context, g auth.Grant, column any, args ...any) (*Ticket, error)

func (*TicketQuery) ForPage

func (q *TicketQuery) ForPage(page, perPage int) *TicketQuery

func (*TicketQuery) ForceCreate

func (q *TicketQuery) ForceCreate(ctx context.Context, g auth.Grant, attributes map[string]any) (*Ticket, error)

func (*TicketQuery) Get

func (q *TicketQuery) Get(ctx context.Context, g auth.Grant, columns ...any) (TicketCollection, error)

Get runs the query and returns the rows.

func (*TicketQuery) GroupBy

func (q *TicketQuery) GroupBy(groups ...any) *TicketQuery

func (*TicketQuery) Has

func (q *TicketQuery) Has(relation, operator string, count int, boolean string, callback func(*query.Builder)) *TicketQuery

func (*TicketQuery) Increment

func (q *TicketQuery) Increment(ctx context.Context, g auth.Grant, column string, amount any, extra map[string]any) (int64, error)

func (*TicketQuery) Insert

func (q *TicketQuery) Insert(ctx context.Context, g auth.Grant, values ...map[string]any) (bool, error)

func (*TicketQuery) InsertOrIgnore

func (q *TicketQuery) InsertOrIgnore(ctx context.Context, g auth.Grant, values ...map[string]any) (bool, error)

func (*TicketQuery) Join

func (q *TicketQuery) Join(table any, first any, args ...any) *TicketQuery

func (*TicketQuery) Latest

func (q *TicketQuery) Latest(column ...string) *TicketQuery

func (*TicketQuery) Limit

func (q *TicketQuery) Limit(value int) *TicketQuery

func (*TicketQuery) Max

func (q *TicketQuery) Max(ctx context.Context, g auth.Grant, column any) (any, error)

func (*TicketQuery) Min

func (q *TicketQuery) Min(ctx context.Context, g auth.Grant, column any) (any, error)

func (*TicketQuery) New

func (q *TicketQuery) New() (*Ticket, error)

New returns an empty Ticket on the query's connection, not yet saved, filled with what WithAttributes recorded on the query.

func (*TicketQuery) Offset

func (q *TicketQuery) Offset(value int) *TicketQuery

func (*TicketQuery) Oldest

func (q *TicketQuery) Oldest(column ...string) *TicketQuery

func (*TicketQuery) OrDoesntHave

func (q *TicketQuery) OrDoesntHave(relation string) *TicketQuery

func (*TicketQuery) OrHas

func (q *TicketQuery) OrHas(relation, operator string, count int) *TicketQuery

func (*TicketQuery) OrWhere

func (q *TicketQuery) OrWhere(column any, args ...any) *TicketQuery

OrWhere adds an or-where clause; a func(*TicketQuery) in place of the column adds a group.

func (*TicketQuery) OrWhereDoesntHave

func (q *TicketQuery) OrWhereDoesntHave(relation string, callback func(*query.Builder)) *TicketQuery

func (*TicketQuery) OrWhereHas

func (q *TicketQuery) OrWhereHas(relation string, callback func(*query.Builder)) *TicketQuery

func (*TicketQuery) OrWhereNot

func (q *TicketQuery) OrWhereNot(column any, args ...any) *TicketQuery

OrWhereNot adds an or-joined negated where clause, or a negated group.

func (*TicketQuery) OrWhereRelation

func (q *TicketQuery) OrWhereRelation(relation string, column any, args ...any) *TicketQuery

func (*TicketQuery) OrderBy

func (q *TicketQuery) OrderBy(column any, direction ...string) *TicketQuery

func (*TicketQuery) OrderByDesc

func (q *TicketQuery) OrderByDesc(column any) *TicketQuery

func (*TicketQuery) Paginate

func (q *TicketQuery) Paginate(ctx context.Context, g auth.Grant, perPage, page int, opts pagination.Options, columns ...any) (TicketCollection, *pagination.LengthAwarePage, error)

func (*TicketQuery) Pluck

func (q *TicketQuery) Pluck(ctx context.Context, g auth.Grant, column string) ([]any, error)

func (*TicketQuery) Select

func (q *TicketQuery) Select(columns ...any) *TicketQuery

func (*TicketQuery) SelectRaw

func (q *TicketQuery) SelectRaw(expression string, bindings ...any) *TicketQuery

func (*TicketQuery) SimplePaginate

func (q *TicketQuery) SimplePaginate(ctx context.Context, g auth.Grant, perPage, page int, opts pagination.Options, columns ...any) (TicketCollection, *pagination.Page, error)

func (*TicketQuery) Sole

func (q *TicketQuery) Sole(ctx context.Context, g auth.Grant, columns ...any) (*Ticket, error)

func (*TicketQuery) SoleValue

func (q *TicketQuery) SoleValue(ctx context.Context, g auth.Grant, column string) (any, error)

func (*TicketQuery) Sum

func (q *TicketQuery) Sum(ctx context.Context, g auth.Grant, column any) (any, error)

func (*TicketQuery) Touch

func (q *TicketQuery) Touch(ctx context.Context, g auth.Grant, column ...string) (int64, error)

func (*TicketQuery) Update

func (q *TicketQuery) Update(ctx context.Context, g auth.Grant, values map[string]any) (int64, error)

func (*TicketQuery) UpdateOrCreate

func (q *TicketQuery) UpdateOrCreate(ctx context.Context, g auth.Grant, attributes, values map[string]any) (*Ticket, error)

func (*TicketQuery) Upsert

func (q *TicketQuery) Upsert(ctx context.Context, g auth.Grant, values []map[string]any, uniqueBy, update []string) (int64, error)

func (*TicketQuery) Value

func (q *TicketQuery) Value(ctx context.Context, g auth.Grant, column string) (any, error)

func (*TicketQuery) ValueOrFail

func (q *TicketQuery) ValueOrFail(ctx context.Context, g auth.Grant, column string) (any, error)

func (*TicketQuery) Where

func (q *TicketQuery) Where(column any, args ...any) *TicketQuery

Where adds a where clause; a func(*TicketQuery) in place of the column adds a group.

func (*TicketQuery) WhereBelongsTo

func (q *TicketQuery) WhereBelongsTo(relationshipName string, related ...model.Entity) *TicketQuery

func (*TicketQuery) WhereBetween

func (q *TicketQuery) WhereBetween(column any, from, to any) *TicketQuery

func (*TicketQuery) WhereColumn

func (q *TicketQuery) WhereColumn(first any, args ...any) *TicketQuery

func (*TicketQuery) WhereDoesntHave

func (q *TicketQuery) WhereDoesntHave(relation string, callback func(*query.Builder)) *TicketQuery

func (*TicketQuery) WhereExists

func (q *TicketQuery) WhereExists(callback func(*query.Builder)) *TicketQuery

func (*TicketQuery) WhereHas

func (q *TicketQuery) WhereHas(relation string, callback func(*query.Builder)) *TicketQuery

func (*TicketQuery) WhereHasCount

func (q *TicketQuery) WhereHasCount(relation string, callback func(*query.Builder), operator string, count int) *TicketQuery

func (*TicketQuery) WhereIn

func (q *TicketQuery) WhereIn(column any, values []any) *TicketQuery

func (*TicketQuery) WhereKey

func (q *TicketQuery) WhereKey(id any) *TicketQuery

func (*TicketQuery) WhereNot

func (q *TicketQuery) WhereNot(column any, args ...any) *TicketQuery

WhereNot adds a negated where clause, or a negated group.

func (*TicketQuery) WhereNotExists

func (q *TicketQuery) WhereNotExists(callback func(*query.Builder)) *TicketQuery

func (*TicketQuery) WhereNotIn

func (q *TicketQuery) WhereNotIn(column any, values []any) *TicketQuery

func (*TicketQuery) WhereNotNull

func (q *TicketQuery) WhereNotNull(columns ...any) *TicketQuery

func (*TicketQuery) WhereNull

func (q *TicketQuery) WhereNull(columns ...any) *TicketQuery

func (*TicketQuery) WhereRelation

func (q *TicketQuery) WhereRelation(relation string, column any, args ...any) *TicketQuery

func (*TicketQuery) With

func (q *TicketQuery) With(relations ...string) *TicketQuery

func (*TicketQuery) WithAvg

func (q *TicketQuery) WithAvg(relation, column string) *TicketQuery

func (*TicketQuery) WithCount

func (q *TicketQuery) WithCount(relations ...string) *TicketQuery

func (*TicketQuery) WithExists

func (q *TicketQuery) WithExists(relation string) *TicketQuery

func (*TicketQuery) WithMax

func (q *TicketQuery) WithMax(relation, column string) *TicketQuery

func (*TicketQuery) WithMin

func (q *TicketQuery) WithMin(relation, column string) *TicketQuery

func (*TicketQuery) WithSum

func (q *TicketQuery) WithSum(relation, column string) *TicketQuery

func (*TicketQuery) Without

func (q *TicketQuery) Without(relations ...string) *TicketQuery

func (*TicketQuery) WithoutGlobalScope

func (q *TicketQuery) WithoutGlobalScope(identifier string) *TicketQuery

func (*TicketQuery) WithoutGlobalScopes

func (q *TicketQuery) WithoutGlobalScopes(identifiers ...string) *TicketQuery

type TicketRow

type TicketRow struct {
	ID, Code, Subject, Category string
	Status, StatusKey           string
	Priority, PriorityKey       string
	Requester, RequesterEmail   string
	Assignee, AssigneeID        string
	Space, Origin               string
	Created, Updated            string
	// CreatedSort and UpdatedSort order like the instants they stand for.
	CreatedSort, UpdatedSort string
	URL                      string
	Closed, Resolved         bool
}

TicketRow is one ticket as a list or a conversation shows it.

type TicketService

type TicketService struct {
	// contains filtered or unexported fields
}

TicketService orchestrates what a customer does with tickets: it validates the request, asks the policy for a Grant, applies the rules the entity holds for itself, and persists with that Grant.

It receives its collaborators through the constructor. There is no container and no resolution by reflection: what this service is made of is written at the one place that builds it, and reading that place is how somebody learns what the package touches.

Everything a handler is allowed to do for a customer goes through here. The service is the only owner of the database handle on this side, so the request layer cannot reach a Model before the policy has answered.

func NewTicketService

func NewTicketService(db *data.DB, cfg Config) *TicketService

NewTicketService wires the service over the application's database handle.

func (*TicketService) Attachment

func (s *TicketService) Attachment(ctx context.Context, actor security.Subject, ticketID, attachmentID string) (*Attachment, filesystem.File, error)

Attachment opens a file of a message the customer may read. The caller closes the body. Reading it is recorded.

func (*TicketService) CanOpen

func (s *TicketService) CanOpen(ctx context.Context, actor security.Subject) error

CanOpen answers whether the subject may open a ticket, for the screen that draws the form. It asks the policy exactly as Open does, and the refusal it returns is the one Open would.

func (*TicketService) Close

func (s *TicketService) Close(ctx context.Context, actor security.Subject, id string) (*Ticket, error)

Close closes a ticket the customer may read.

func (*TicketService) Find

Find returns one conversation: the ticket and the messages the customer may read. An internal note is not among them, and nothing says one exists. A ticket of another space, and one of the same space the subject may not read, are not found.

func (*TicketService) List

func (s *TicketService) List(ctx context.Context, actor security.Subject, q ListQuery) (Page, error)

List returns a page of the customer's tickets: their own, or the whole space's when the policy allows it, searched, filtered by status and ordered by the server.

func (*TicketService) Open

func (s *TicketService) Open(ctx context.Context, actor security.Subject, in OpenRequest, uploads []Upload) (*Ticket, error)

Open opens a ticket for the signed-in customer, in their own space, with the first message and the files that came with it.

func (*TicketService) OpenFromContact

func (s *TicketService) OpenFromContact(ctx context.Context, in ContactInput) (ContactResult, error)

OpenFromContact opens a ticket from the application's public contact form, and only for somebody who has an account.

The e-mail is asked of Config.Accounts. When it belongs to an account, the ticket opens in that account's space, with the user as its requester, the subject and the body as its first message and the contact form as its origin. When it does not, nothing is created and the answer is ErrNoAccount: the application keeps doing what it did with the form before, which is sending the e-mail.

The same e-mail, subject and body sent twice inside Config.ContactWindow open one ticket: the second answer is the first ticket, with Duplicate set. Two requests at the same instant are told apart by the tenant's counter row, so neither can slip past the other.

There is no session behind the form, so the Grant is the system's, scoped to the tenant the application's AccountResolver answered, and the sender is who the form says they are: anybody can type an address. The ticket therefore carries its origin, and a ticket the customer did not write is a ticket the customer can close. An application that must not accept a form from just anybody puts its own check -- a captcha, a rate limit -- in front of the call.

func (*TicketService) Reopen

func (s *TicketService) Reopen(ctx context.Context, actor security.Subject, id string) (*Ticket, error)

Reopen brings a resolved or closed ticket back to the team.

func (*TicketService) Reply

func (s *TicketService) Reply(ctx context.Context, actor security.Subject, id string, in ReplyRequest, uploads []Upload) (*Message, error)

Reply adds the customer's message to a ticket. A ticket nobody answered yet stays open; any other waits for the team. A closed ticket takes no reply: it is reopened first.

type URLs

type URLs struct {
	// contains filtered or unexported fields
}

URLs are the addresses of the routes under the module's configuration. A link is built from here and never written out a second time: under another Prefix the paths move and the names do not.

func NewURLs

func NewURLs(prefix, teamPrefix string, paths Paths) URLs

NewURLs are the addresses of the routes under a pair of prefixes and the segments, for a screen built outside a request: a test that draws a page, an e-mail that links to one. Empty values are the defaults.

func (URLs) Attachment

func (u URLs) Attachment(id, attachment string) string

Attachment is a file of a message, for the customer.

func (URLs) Close

func (u URLs) Close(id string) string

Close is where the customer posts a closing.

func (URLs) Index

func (u URLs) Index() string

Index is the customer's list.

func (URLs) New

func (u URLs) New() string

New is the form that opens a ticket.

func (URLs) Reopen

func (u URLs) Reopen(id string) string

Reopen is where the customer posts a reopening.

func (URLs) Reply

func (u URLs) Reply(id string) string

Reply is where the customer posts a reply.

func (URLs) Show

func (u URLs) Show(id string) string

Show is one conversation, for the customer.

func (URLs) TeamAssign

func (u URLs) TeamAssign(id string) string

TeamAssign is where the team posts a new assignee.

func (URLs) TeamAttachment

func (u URLs) TeamAttachment(id, attachment string) string

TeamAttachment is a file of a message, for the team.

func (URLs) TeamIndex

func (u URLs) TeamIndex() string

TeamIndex is the team's list.

func (URLs) TeamNote

func (u URLs) TeamNote(id string) string

TeamNote is where the team posts an internal note.

func (URLs) TeamPriority

func (u URLs) TeamPriority(id string) string

TeamPriority is where the team posts a new priority.

func (URLs) TeamReply

func (u URLs) TeamReply(id string) string

TeamReply is where the team posts an answer.

func (URLs) TeamShow

func (u URLs) TeamShow(id string) string

TeamShow is one conversation, for the team.

func (URLs) TeamStatus

func (u URLs) TeamStatus(id string) string

TeamStatus is where the team posts a new status.

type Upload

type Upload struct {
	// Name is the file's name as the sender had it. Only its last element is
	// kept.
	Name string
	// Body is the file's bytes. The package reads at most Config.MaxAttachmentBytes
	// of it, and decides what it is by looking, not by what it is called.
	Body io.Reader
}

Upload is a file sent with a message.

Directories

Path Synopsis
internal
platform
Package platform is the tickets table read without the tenant filter: the one read of arandu-ticket that crosses tenants, the team's list and the lookup that finds which tenant a ticket is in, after the team's policy allowed it.
Package platform is the tickets table read without the tenant filter: the one read of arandu-ticket that crosses tenants, the team's list and the lookup that finds which tenant a ticket is in, after the team's policy allowed it.

Jump to

Keyboard shortcuts

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