todo.sr.ht/api/graph/events.go

425 lines
11 KiB
Go

package graph
import (
"context"
"database/sql"
"strings"
"text/template"
"git.sr.ht/~sircmpwn/core-go/config"
"git.sr.ht/~sircmpwn/core-go/email"
sq "github.com/Masterminds/squirrel"
"github.com/emersion/go-message/mail"
"git.sr.ht/~sircmpwn/todo.sr.ht/api/graph/model"
"git.sr.ht/~sircmpwn/todo.sr.ht/api/loaders"
)
type NewTicketDetails struct {
Body *string
Root string
TicketURL string
}
var newTicketTemplate = template.Must(template.New("new-ticket").Parse(
`{{.Body}}
--
View on the web: {{.Root}}{{.TicketURL}}`))
type TicketStatusDetails struct {
Root string
TicketURL string
EventID int
Status string
Resolution string
}
var ticketStatusTemplate = template.Must(template.New("ticket-status").Parse(
`{{if eq .Status "RESOLVED"}}Ticket resolved: {{.Resolution}}{{end}}
--
View on the web: {{.Root}}{{.TicketURL}}#event-{{.EventID}}`))
type SubmitCommentDetails struct {
Comment string
Root string
TicketURL string
EventID int
Status string
Resolution string
StatusUpdated bool
}
var submitCommentTemplate = template.Must(template.New("ticket-comment").Parse(`
{{- if .StatusUpdated -}}
{{- if eq .Status "RESOLVED" -}}
Ticket resolved: {{.Resolution}}
{{else -}}
Ticket re-opened: {{.Status}}
{{end}}{{end -}}
{{.Comment }}
--
View on the web: {{.Root}}{{.TicketURL}}#event-{{.EventID}}`))
type TicketAssignedDetails struct {
Root string
TicketURL string
EventID int
Assigned bool
Assigner string
Assignee string
}
var ticketAssignedTemplate = template.Must(template.New("ticket-assigned").Parse(`
{{- if .Assigned -}}
~{{.Assigner}} assigned this ticket to ~{{.Assignee}}
{{- else -}}
~{{.Assigner}} unassigned ~{{.Assignee}} from this ticket
{{- end}}
--
View on the web: {{.Root}}{{.TicketURL}}#event-{{.EventID}}`))
type EventBuilder struct {
ctx context.Context
tx *sql.Tx
eventType uint
submitterID int
tracker *model.Tracker
ticket *model.Ticket
mentionedParticipants []int
mentions *Mentions
}
// Creates a new event builder for a given submitter (a participant ID) and a
// certain event type, using the provided context and transaction. This is used
// to provide a common implementation for creating events and notifications,
// building up the list of implicated participants over time.
//
// The order in which you call the subsequent functions is important:
// 1. WithTicket
// 2. AddMentions
// 3. InsertSubscriptions
// 4. InsertNotifications
func NewEventBuilder(ctx context.Context, tx *sql.Tx,
submitterID int, eventType uint) *EventBuilder {
// Create a temporary table of all participants affected by this
// submission. This includes everyone who will be notified about it.
_, err := tx.ExecContext(ctx, `
CREATE TEMP TABLE event_participant
ON COMMIT DROP
AS (SELECT
-- The affected participant:
$1::INTEGER AS participant_id,
-- Events they should be notified of:
$2::INTEGER AS event_type,
-- Should they be subscribed to this ticket?
true AS subscribe
);
`, submitterID, eventType)
if err != nil {
panic(err)
}
return &EventBuilder{
ctx: ctx,
tx: tx,
eventType: eventType,
submitterID: submitterID,
}
}
// Associates this event with a ticket and implicates the submitter for the
// appropriate events and notifications.
func (builder *EventBuilder) WithTicket(
tracker *model.Tracker, ticket *model.Ticket) *EventBuilder {
builder.tracker = tracker
builder.ticket = ticket
_, err := builder.tx.ExecContext(builder.ctx, `
INSERT INTO event_participant (
participant_id, event_type, subscribe
)
SELECT sub.participant_id, $1, false
FROM ticket_subscription sub
WHERE sub.tracker_id = $2 OR sub.ticket_id = $3
`, builder.eventType, tracker.ID, ticket.PKID)
if err != nil {
panic(err)
}
return builder
}
// Adds mentions to this event builder
func (builder *EventBuilder) AddMentions(mentions *Mentions) {
builder.mentions = mentions
for user, _ := range mentions.Users {
part, err := loaders.ForContext(builder.ctx).ParticipantsByUsername.Load(user)
if err != nil {
panic(err)
}
if part == nil {
continue
}
_, err = builder.tx.ExecContext(builder.ctx, `
INSERT INTO event_participant (
participant_id, event_type, subscribe
) VALUES (
$1, $2, true
);
`, part.ID, model.EVENT_USER_MENTIONED)
if err != nil {
panic(err)
}
builder.mentionedParticipants = append(builder.mentionedParticipants, part.ID)
}
}
// Creates subscriptions for all affected users
func (builder *EventBuilder) InsertSubscriptions() {
_, err := builder.tx.ExecContext(builder.ctx, `
INSERT INTO ticket_subscription (
created, updated, ticket_id, participant_id
)
SELECT
NOW() at time zone 'utc',
NOW() at time zone 'utc',
$1, participant_id
FROM event_participant
WHERE subscribe = true
ON CONFLICT ON CONSTRAINT subscription_ticket_participant_uq
DO NOTHING;
`, builder.ticket.PKID)
if err != nil {
panic(err)
}
}
// Adds event_notification records for all affected users and inserts
// ancillary events (such as mentions) and their notifications.
func (builder *EventBuilder) InsertNotifications(eventID int, commentID *int) {
_, err := builder.tx.ExecContext(builder.ctx, `
INSERT INTO event_notification (created, event_id, user_id)
SELECT
NOW() at time zone 'utc',
$1, part.user_id
FROM event_participant ev
JOIN participant part ON part.id = ev.participant_id
WHERE part.user_id IS NOT NULL AND ev.event_type = $2;
`, eventID, builder.eventType)
if err != nil {
panic(err)
}
if builder.mentions == nil {
return
}
for _, id := range builder.mentionedParticipants {
var eventID int
row := builder.tx.QueryRowContext(builder.ctx, `
INSERT INTO event (
created, event_type, participant_id, by_participant_id,
ticket_id, from_ticket_id, comment_id
) VALUES (
NOW() at time zone 'utc',
$1, $2, $3, $4, $4, $5
) RETURNING id;`,
model.EVENT_USER_MENTIONED, id, builder.submitterID,
builder.ticket.PKID, commentID)
if err := row.Scan(&eventID); err != nil {
panic(err)
}
_, err := builder.tx.ExecContext(builder.ctx, `
INSERT INTO event_notification (created, event_id, user_id)
SELECT
NOW() at time zone 'utc',
$1, part.user_id
FROM event_participant ev
JOIN participant part ON part.id = ev.participant_id
WHERE part.user_id IS NOT NULL AND ev.event_type = $2;
`, eventID, model.EVENT_USER_MENTIONED)
if err != nil {
panic(err)
}
}
for _, target := range builder.mentions.Tickets {
_, err := builder.tx.ExecContext(builder.ctx, `
WITH target AS (
SELECT tk.id
FROM ticket tk
JOIN tracker tr ON tk.tracker_id = tr.id
JOIN "user" u ON u.id = tr.owner_id
WHERE u.username = $1 AND tr.name = $2 AND tk.scoped_id = $3
)
INSERT INTO event (
created, event_type, by_participant_id, ticket_id,
from_ticket_id, comment_id
) VALUES (
NOW() at time zone 'utc',
$4, $5, (SELECT id FROM target), $6, $7
)`,
target.OwnerName, target.TrackerName, target.ID,
model.EVENT_TICKET_MENTIONED, builder.submitterID,
builder.ticket.PKID, commentID)
if err != nil {
panic(err)
}
}
}
func (builder *EventBuilder) SendEmails(subject string,
template *template.Template, context interface{}) {
var (
rcpts []mail.Address
submitterName string
submitterEmail string
notifySelf, copiedSelf bool
)
row := builder.tx.QueryRowContext(builder.ctx, `
SELECT
CASE part.participant_type
WHEN 'user' THEN '~' || "user".username
WHEN 'email' THEN part.email_name
ELSE '' END,
CASE part.participant_type
WHEN 'user' THEN '~' || "user".email
WHEN 'email' THEN part.email
ELSE '' END,
CASE part.participant_type
WHEN 'user' THEN "user".notify_self
ELSE false END
FROM participant part
LEFT JOIN "user" ON "user".id = part.user_id
WHERE part.id = $1
`, builder.submitterID)
if err := row.Scan(&submitterName, &submitterEmail, &notifySelf); err != nil {
panic(err)
}
// XXX: It may be possible to implement this more efficiently by skipping
// the joins and pre-stashing the email details when inserting
// event_participants.
subs := sq.Select(`
CASE part.participant_type
WHEN 'user' THEN '~' || "user".username
WHEN 'email' THEN part.email_name
ELSE '' END
`, `
CASE part.participant_type
WHEN 'user' THEN "user".email
WHEN 'email' THEN part.email
ELSE '' END
`).
Distinct().
From(`event_participant evpart`).
Join(`participant part ON evpart.participant_id = part.id`).
LeftJoin(`"user" ON "user".id = part.user_id`)
rows, err := subs.
PlaceholderFormat(sq.Dollar).
RunWith(builder.tx).
QueryContext(builder.ctx)
if err != nil && err != sql.ErrNoRows {
panic(err)
}
set := make(map[string]interface{})
for rows.Next() {
var name, address string
if err := rows.Scan(&name, &address); err != nil {
panic(err)
}
if len(name) == 0 || len(address) == 0 {
continue
}
if address == submitterEmail {
if notifySelf {
copiedSelf = true
} else {
continue
}
}
if _, ok := set[address]; ok {
continue
}
set[address] = nil
rcpts = append(rcpts, mail.Address{
Name: name,
Address: address,
})
}
if notifySelf && !copiedSelf {
rcpts = append(rcpts, mail.Address{
Name: submitterName,
Address: submitterEmail,
})
}
var body strings.Builder
err = template.Execute(&body, context)
if err != nil {
panic(err)
}
conf := config.ForContext(builder.ctx)
var notifyFrom string
if addr, ok := conf.Get("todo.sr.ht", "notify-from"); ok {
notifyFrom = addr
} else if addr, ok := conf.Get("mail", "smtp-from"); ok {
notifyFrom = addr
} else {
panic("Invalid mail configuration")
}
smtpUser, ok := conf.Get("mail", "smtp-user")
if !ok {
panic("Invalid mail configuration")
}
postingDomain, ok := conf.Get("todo.sr.ht::mail", "posting-domain")
if !ok {
panic("Invalid mail configuration")
}
from := mail.Address{
Name: submitterName,
Address: notifyFrom,
}
sender := mail.Address{
Address: smtpUser + "@" + postingDomain,
}
ticketRef := builder.ticket.EmailRef(postingDomain)
ticketAddress := mail.Address{
Name: builder.ticket.Ref(),
Address: ticketRef,
}
for _, rcpt := range rcpts {
var header mail.Header
// TODO: List-Unsubscribe header
header.SetAddressList("To", []*mail.Address{&rcpt})
header.SetAddressList("From", []*mail.Address{&from})
header.SetAddressList("Reply-To", []*mail.Address{&ticketAddress})
header.SetAddressList("Sender", []*mail.Address{&sender})
if builder.eventType == model.EVENT_CREATED {
header.SetMessageID(ticketRef)
} else {
header.SetMsgIDList("In-Reply-To", []string{ticketRef})
}
header.SetSubject(subject)
// TODO: Fetch user PGP key (or send via meta.sr.ht API?)
err = email.EnqueueStd(builder.ctx, header,
strings.NewReader(body.String()), nil)
if err != nil {
panic(err)
}
}
}