2047 lines
60 KiB
Go
2047 lines
60 KiB
Go
package graph
|
|
|
|
// This file will be automatically regenerated based on the schema, any resolver implementations
|
|
// will be copied through when generating and any unknown code will be moved to the end.
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"git.sr.ht/~sircmpwn/core-go/auth"
|
|
"git.sr.ht/~sircmpwn/core-go/config"
|
|
"git.sr.ht/~sircmpwn/core-go/database"
|
|
coremodel "git.sr.ht/~sircmpwn/core-go/model"
|
|
"git.sr.ht/~sircmpwn/core-go/valid"
|
|
"git.sr.ht/~sircmpwn/todo.sr.ht/api/graph/api"
|
|
"git.sr.ht/~sircmpwn/todo.sr.ht/api/graph/model"
|
|
"git.sr.ht/~sircmpwn/todo.sr.ht/api/loaders"
|
|
"git.sr.ht/~sircmpwn/todo.sr.ht/api/webhooks"
|
|
"github.com/99designs/gqlgen/graphql"
|
|
sq "github.com/Masterminds/squirrel"
|
|
"github.com/lib/pq"
|
|
)
|
|
|
|
func (r *assignmentResolver) Ticket(ctx context.Context, obj *model.Assignment) (*model.Ticket, error) {
|
|
return loaders.ForContext(ctx).TicketsByID.Load(obj.TicketID)
|
|
}
|
|
|
|
func (r *assignmentResolver) Assigner(ctx context.Context, obj *model.Assignment) (model.Entity, error) {
|
|
return loaders.ForContext(ctx).EntitiesByParticipantID.Load(obj.AssignerID)
|
|
}
|
|
|
|
func (r *assignmentResolver) Assignee(ctx context.Context, obj *model.Assignment) (model.Entity, error) {
|
|
return loaders.ForContext(ctx).EntitiesByParticipantID.Load(obj.AssigneeID)
|
|
}
|
|
|
|
func (r *commentResolver) Ticket(ctx context.Context, obj *model.Comment) (*model.Ticket, error) {
|
|
return loaders.ForContext(ctx).TicketsByID.Load(obj.TicketID)
|
|
}
|
|
|
|
func (r *commentResolver) Author(ctx context.Context, obj *model.Comment) (model.Entity, error) {
|
|
return loaders.ForContext(ctx).EntitiesByParticipantID.Load(obj.ParticipantID)
|
|
}
|
|
|
|
func (r *commentResolver) Text(ctx context.Context, obj *model.Comment) (string, error) {
|
|
// The only route to this resolver is via event details, which is already
|
|
// authenticated. Further access to other resources is limited to
|
|
// authenticated routes, such as TicketByID.
|
|
comment, err := loaders.ForContext(ctx).CommentsByIDUnsafe.Load(obj.Database.ID)
|
|
return comment.Database.Text, err
|
|
}
|
|
|
|
func (r *commentResolver) Authenticity(ctx context.Context, obj *model.Comment) (model.Authenticity, error) {
|
|
// The only route to this resolver is via event details, which is already
|
|
// authenticated. Further access to other resources is limited to
|
|
// authenticated routes, such as TicketByID.
|
|
comment, err := loaders.ForContext(ctx).CommentsByIDUnsafe.Load(obj.Database.ID)
|
|
return comment.Database.Authenticity, err
|
|
}
|
|
|
|
func (r *commentResolver) SupersededBy(ctx context.Context, obj *model.Comment) (*model.Comment, error) {
|
|
if obj.Database.SuperceededByID == nil {
|
|
return nil, nil
|
|
}
|
|
// The only route to this resolver is via event details, which is already
|
|
// authenticated. Further access to other resources is limited to
|
|
// authenticated routes, such as TicketByID.
|
|
return loaders.ForContext(ctx).CommentsByIDUnsafe.Load(*obj.Database.SuperceededByID)
|
|
}
|
|
|
|
func (r *createdResolver) Ticket(ctx context.Context, obj *model.Created) (*model.Ticket, error) {
|
|
return loaders.ForContext(ctx).TicketsByID.Load(obj.TicketID)
|
|
}
|
|
|
|
func (r *createdResolver) Author(ctx context.Context, obj *model.Created) (model.Entity, error) {
|
|
return loaders.ForContext(ctx).EntitiesByParticipantID.Load(obj.ParticipantID)
|
|
}
|
|
|
|
func (r *eventResolver) Ticket(ctx context.Context, obj *model.Event) (*model.Ticket, error) {
|
|
return loaders.ForContext(ctx).TicketsByID.Load(obj.TicketID)
|
|
}
|
|
|
|
func (r *labelResolver) Tracker(ctx context.Context, obj *model.Label) (*model.Tracker, error) {
|
|
return loaders.ForContext(ctx).TrackersByID.Load(obj.TrackerID)
|
|
}
|
|
|
|
func (r *labelResolver) Tickets(ctx context.Context, obj *model.Label, cursor *coremodel.Cursor) (*model.TicketCursor, error) {
|
|
if cursor == nil {
|
|
cursor = coremodel.NewCursor(nil)
|
|
}
|
|
|
|
var tickets []*model.Ticket
|
|
if err := database.WithTx(ctx, &sql.TxOptions{
|
|
Isolation: 0,
|
|
ReadOnly: true,
|
|
}, func(tx *sql.Tx) error {
|
|
// No authentication necessary: if you have access to the label you
|
|
// have access to the tickets.
|
|
ticket := (&model.Ticket{}).As(`tk`)
|
|
query := database.
|
|
Select(ctx, ticket).
|
|
From(`ticket tk`).
|
|
Join(`ticket_label tl ON tl.ticket_id = tk.id`).
|
|
Where(`tl.label_id = ?`, obj.ID)
|
|
tickets, cursor = ticket.QueryWithCursor(ctx, tx, query, cursor)
|
|
return nil
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &model.TicketCursor{tickets, cursor}, nil
|
|
}
|
|
|
|
func (r *labelUpdateResolver) Ticket(ctx context.Context, obj *model.LabelUpdate) (*model.Ticket, error) {
|
|
return loaders.ForContext(ctx).TicketsByID.Load(obj.TicketID)
|
|
}
|
|
|
|
func (r *labelUpdateResolver) Labeler(ctx context.Context, obj *model.LabelUpdate) (model.Entity, error) {
|
|
return loaders.ForContext(ctx).EntitiesByParticipantID.Load(obj.ParticipantID)
|
|
}
|
|
|
|
func (r *labelUpdateResolver) Label(ctx context.Context, obj *model.LabelUpdate) (*model.Label, error) {
|
|
return loaders.ForContext(ctx).LabelsByID.Load(obj.LabelID)
|
|
}
|
|
|
|
func (r *mutationResolver) CreateTracker(ctx context.Context, name string, description *string, visibility model.Visibility, importArg *graphql.Upload) (*model.Tracker, error) {
|
|
valid := valid.New(ctx)
|
|
valid.Expect(trackerNameRE.MatchString(name), "Name must match %s", trackerNameRE.String()).
|
|
WithField("name").
|
|
And(name != "." && name != ".." && name != ".git" && name != ".hg",
|
|
"This is a reserved name and cannot be used for user trakcers.").
|
|
WithField("name")
|
|
// TODO: Unify description limits
|
|
valid.Expect(description == nil || len(*description) < 8192,
|
|
"Description must be fewer than 8192 characters").
|
|
WithField("description")
|
|
valid.Expect(importArg == nil, "TODO: imports").WithField("import") // TODO
|
|
if !valid.Ok() {
|
|
return nil, nil
|
|
}
|
|
|
|
user := auth.ForContext(ctx)
|
|
part, err := loaders.ForContext(ctx).ParticipantsByUserID.Load(user.UserID)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
var tracker model.Tracker
|
|
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
|
|
row := tx.QueryRowContext(ctx, `
|
|
INSERT INTO tracker (
|
|
created, updated,
|
|
owner_id, name, description, visibility
|
|
) VALUES (
|
|
NOW() at time zone 'utc',
|
|
NOW() at time zone 'utc',
|
|
$1, $2, $3, $4
|
|
)
|
|
RETURNING id, owner_id, created, updated, name, description,
|
|
visibility, default_access;
|
|
`, user.UserID, name, description, visibility.String())
|
|
|
|
if err := row.Scan(&tracker.ID, &tracker.OwnerID, &tracker.Created,
|
|
&tracker.Updated, &tracker.Name, &tracker.Description,
|
|
&tracker.Visibility, &tracker.DefaultAccess); err != nil {
|
|
if err, ok := err.(*pq.Error); ok &&
|
|
err.Code == "23505" && // unique_violation
|
|
err.Constraint == "tracker_owner_id_name_unique" {
|
|
valid.Error("A tracker by this name already exists.").
|
|
WithField("name")
|
|
return errors.New("placeholder") // To rollback the transaction
|
|
}
|
|
return err
|
|
}
|
|
tracker.Access = model.ACCESS_ALL
|
|
|
|
_, err := tx.ExecContext(ctx, `
|
|
INSERT INTO ticket_subscription (
|
|
created, updated, tracker_id, participant_id
|
|
) VALUES (
|
|
NOW() at time zone 'utc',
|
|
NOW() at time zone 'utc',
|
|
$1, $2
|
|
);
|
|
`, tracker.ID, part.ID)
|
|
return err
|
|
}); err != nil {
|
|
if !valid.Ok() {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
webhooks.DeliverLegacyTrackerEvent(ctx, &tracker, "tracker:create")
|
|
return &tracker, nil
|
|
}
|
|
|
|
func (r *mutationResolver) UpdateTracker(ctx context.Context, id int, input map[string]interface{}) (*model.Tracker, error) {
|
|
valid := valid.New(ctx).WithInput(input)
|
|
|
|
valid.OptionalString("description", func(desc string) {
|
|
valid.Expect(len(desc) < 8192,
|
|
"Description must be fewer than 8192 characters").
|
|
WithField("description")
|
|
})
|
|
valid.OptionalString("visibility", func(vis string) {
|
|
input["visibility"] = model.Visibility(vis)
|
|
})
|
|
if !valid.Ok() {
|
|
return nil, nil
|
|
}
|
|
|
|
tracker, err := loaders.ForContext(ctx).TrackersByID.Load(id)
|
|
if err != nil || tracker == nil {
|
|
return nil, err
|
|
}
|
|
if tracker.OwnerID != auth.ForContext(ctx).UserID {
|
|
return nil, fmt.Errorf("Access denied")
|
|
}
|
|
|
|
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
|
|
var err error
|
|
if len(input) != 0 {
|
|
_, err = database.Apply(tracker, input).
|
|
Where(database.WithAlias(tracker.Alias(), `id`)+"= ?", tracker.ID).
|
|
Set(database.WithAlias(tracker.Alias(), `updated`),
|
|
sq.Expr(`now() at time zone 'utc'`)).
|
|
RunWith(tx).
|
|
ExecContext(ctx)
|
|
}
|
|
return err
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
webhooks.DeliverLegacyTrackerEvent(ctx, tracker, "tracker:update")
|
|
return tracker, nil
|
|
}
|
|
|
|
func (r *mutationResolver) DeleteTracker(ctx context.Context, id int) (*model.Tracker, error) {
|
|
user := auth.ForContext(ctx)
|
|
|
|
var tracker model.Tracker
|
|
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
|
|
row := tx.QueryRowContext(ctx, `
|
|
DELETE FROM tracker
|
|
WHERE id = $1 AND owner_id = $2
|
|
RETURNING
|
|
id, owner_id, created, updated, name, description, visibility;
|
|
`, id, user.UserID)
|
|
|
|
if err := row.Scan(&tracker.ID, &tracker.OwnerID, &tracker.Created,
|
|
&tracker.Updated, &tracker.Name, &tracker.Description,
|
|
&tracker.Visibility); err != nil {
|
|
return err
|
|
}
|
|
tracker.Access = model.ACCESS_ALL
|
|
return nil
|
|
}); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
webhooks.DeliverLegacyTrackerDelete(ctx, tracker.ID, user.UserID)
|
|
return &tracker, nil
|
|
}
|
|
|
|
func (r *mutationResolver) UpdateUserACL(ctx context.Context, trackerID int, userID int, input model.ACLInput) (*model.TrackerACL, error) {
|
|
var acl model.TrackerACL
|
|
bits := aclBits(input)
|
|
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
|
|
user := auth.ForContext(ctx)
|
|
row := tx.QueryRowContext(ctx, `
|
|
INSERT INTO user_access (
|
|
created, tracker_id, user_id, permissions
|
|
) VALUES (
|
|
NOW() at time zone 'utc',
|
|
-- The purpose of this is to filter out tracker that the user is
|
|
-- not an owner of. Saves us a round-trip
|
|
(SELECT id FROM tracker WHERE id = $1 AND owner_id = $4),
|
|
$2, $3
|
|
)
|
|
ON CONFLICT ON CONSTRAINT idx_useraccess_tracker_user_unique
|
|
DO UPDATE SET permissions = $3
|
|
RETURNING id, created, tracker_id, user_id;
|
|
`, trackerID, userID, bits, user.UserID)
|
|
|
|
if err := row.Scan(&acl.ID, &acl.Created, &acl.TrackerID,
|
|
&acl.UserID); err != nil {
|
|
return err
|
|
}
|
|
|
|
acl.SetBits(bits)
|
|
return nil
|
|
}); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
return &acl, nil
|
|
}
|
|
|
|
func (r *mutationResolver) UpdateTrackerACL(ctx context.Context, trackerID int, input model.ACLInput) (*model.DefaultACL, error) {
|
|
bits := aclBits(input)
|
|
user := auth.ForContext(ctx)
|
|
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
|
|
_, err := tx.ExecContext(ctx, `
|
|
UPDATE tracker
|
|
SET default_access = $1
|
|
WHERE id = $2 AND owner_id = $3;
|
|
`, bits, trackerID, user.UserID)
|
|
return err
|
|
}); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
acl := &model.DefaultACL{}
|
|
acl.SetBits(bits)
|
|
return acl, nil
|
|
}
|
|
|
|
func (r *mutationResolver) DeleteACL(ctx context.Context, id int) (*model.TrackerACL, error) {
|
|
var acl model.TrackerACL
|
|
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
|
|
user := auth.ForContext(ctx)
|
|
row := tx.QueryRowContext(ctx, `
|
|
DELETE FROM user_access ua
|
|
USING tracker
|
|
WHERE
|
|
ua.tracker_id = tracker.id AND
|
|
ua.id = $1 AND
|
|
tracker.owner_id = $2
|
|
RETURNING ua.id, ua.created, ua.tracker_id, ua.user_id, ua.permissions;
|
|
`, id, user.UserID)
|
|
|
|
var bits uint
|
|
if err := row.Scan(&acl.ID, &acl.Created, &acl.TrackerID,
|
|
&acl.UserID, &bits); err != nil {
|
|
return err
|
|
}
|
|
|
|
acl.SetBits(bits)
|
|
return nil
|
|
}); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
return &acl, nil
|
|
}
|
|
|
|
func (r *mutationResolver) TrackerSubscribe(ctx context.Context, trackerID int) (*model.TrackerSubscription, error) {
|
|
var sub model.TrackerSubscription
|
|
|
|
user := auth.ForContext(ctx)
|
|
tracker, err := loaders.ForContext(ctx).TrackersByID.Load(trackerID)
|
|
if err != nil {
|
|
return nil, err
|
|
} else if tracker == nil {
|
|
return nil, fmt.Errorf("Access denied")
|
|
}
|
|
part, err := loaders.ForContext(ctx).ParticipantsByUserID.Load(user.UserID)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
|
|
row := tx.QueryRowContext(ctx, `
|
|
INSERT INTO ticket_subscription (
|
|
created, updated, tracker_id, participant_id
|
|
) VALUES (
|
|
NOW() at time zone 'utc',
|
|
NOW() at time zone 'utc',
|
|
$1, $2
|
|
)
|
|
ON CONFLICT ON CONSTRAINT subscription_tracker_participant_uq
|
|
DO UPDATE SET updated = NOW() at time zone 'utc'
|
|
RETURNING id, created, tracker_id;
|
|
`, tracker.ID, part.ID)
|
|
return row.Scan(&sub.ID, &sub.Created, &sub.TrackerID)
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
return &sub, nil
|
|
}
|
|
|
|
func (r *mutationResolver) TrackerUnsubscribe(ctx context.Context, trackerID int, tickets bool) (*model.TrackerSubscription, error) {
|
|
var sub model.TrackerSubscription
|
|
|
|
user := auth.ForContext(ctx)
|
|
part, err := loaders.ForContext(ctx).ParticipantsByUserID.Load(user.UserID)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
if tickets {
|
|
panic("not implemented") // TODO
|
|
}
|
|
|
|
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
|
|
row := tx.QueryRowContext(ctx, `
|
|
DELETE FROM ticket_subscription
|
|
WHERE tracker_id = $1 AND participant_id = $2
|
|
RETURNING id, created, tracker_id;
|
|
`, trackerID, part.ID)
|
|
return row.Scan(&sub.ID, &sub.Created, &sub.TrackerID)
|
|
}); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
return &sub, nil
|
|
}
|
|
|
|
func (r *mutationResolver) TicketSubscribe(ctx context.Context, trackerID int, ticketID int) (*model.TicketSubscription, error) {
|
|
var sub model.TicketSubscription
|
|
|
|
user := auth.ForContext(ctx)
|
|
part, err := loaders.ForContext(ctx).ParticipantsByUserID.Load(user.UserID)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
ticket, err := loaders.ForContext(ctx).
|
|
TicketsByTrackerID.Load([2]int{trackerID, ticketID})
|
|
if err != nil {
|
|
return nil, err
|
|
} else if ticket == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
|
|
row := tx.QueryRowContext(ctx, `
|
|
INSERT INTO ticket_subscription (
|
|
created, updated, ticket_id, participant_id
|
|
) VALUES (
|
|
NOW() at time zone 'utc',
|
|
NOW() at time zone 'utc',
|
|
$1, $2
|
|
)
|
|
ON CONFLICT ON CONSTRAINT subscription_ticket_participant_uq
|
|
DO UPDATE SET updated = NOW() at time zone 'utc'
|
|
RETURNING id, created, ticket_id;
|
|
`, ticket.PKID, part.ID)
|
|
return row.Scan(&sub.ID, &sub.Created, &sub.TicketID)
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
return &sub, nil
|
|
}
|
|
|
|
func (r *mutationResolver) TicketUnsubscribe(ctx context.Context, trackerID int, ticketID int) (*model.TicketSubscription, error) {
|
|
var sub model.TicketSubscription
|
|
|
|
user := auth.ForContext(ctx)
|
|
part, err := loaders.ForContext(ctx).ParticipantsByUserID.Load(user.UserID)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
ticket, err := loaders.ForContext(ctx).
|
|
TicketsByTrackerID.Load([2]int{trackerID, ticketID})
|
|
if err != nil {
|
|
return nil, err
|
|
} else if ticket == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
|
|
row := tx.QueryRowContext(ctx, `
|
|
DELETE FROM ticket_subscription
|
|
WHERE ticket_id = $1 AND participant_id = $2
|
|
RETURNING id, created, ticket_id;
|
|
`, ticket.PKID, part.ID)
|
|
return row.Scan(&sub.ID, &sub.Created, &sub.TicketID)
|
|
}); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
return &sub, nil
|
|
}
|
|
|
|
func (r *mutationResolver) CreateLabel(ctx context.Context, trackerID int, name string, foreground string, background string) (*model.Label, error) {
|
|
var (
|
|
err error
|
|
label model.Label
|
|
)
|
|
user := auth.ForContext(ctx)
|
|
if _, err = parseColor(foreground); err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err = parseColor(background); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(name) <= 0 {
|
|
return nil, fmt.Errorf("Label name must be greater than zero in length")
|
|
}
|
|
tracker, err := loaders.ForContext(ctx).TrackersByID.Load(trackerID)
|
|
if err != nil {
|
|
return nil, err
|
|
} else if tracker == nil {
|
|
return nil, nil
|
|
}
|
|
if tracker.OwnerID != user.UserID {
|
|
return nil, fmt.Errorf("Access denied")
|
|
}
|
|
|
|
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
|
|
// TODO: Rename the columns for consistency
|
|
row := tx.QueryRowContext(ctx, `
|
|
INSERT INTO label (
|
|
created, updated, tracker_id, name, color, text_color
|
|
) VALUES (
|
|
NOW() at time zone 'utc',
|
|
NOW() at time zone 'utc',
|
|
$1, $2, $3, $4
|
|
) RETURNING id, created, name, color, text_color, tracker_id;
|
|
`, tracker.ID, name, background, foreground)
|
|
|
|
if err := row.Scan(&label.ID, &label.Created, &label.Name,
|
|
&label.BackgroundColor, &label.ForegroundColor,
|
|
&label.TrackerID); err != nil {
|
|
if err, ok := err.(*pq.Error); ok &&
|
|
err.Code == "23505" && // unique_violation
|
|
err.Constraint == "idx_tracker_name_unique" {
|
|
return fmt.Errorf("A label by this name already exists")
|
|
}
|
|
// XXX: This is not ideal
|
|
if err, ok := err.(*pq.Error); ok &&
|
|
err.Code == "23502" { // not_null_violation
|
|
return sql.ErrNoRows
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
webhooks.DeliverLegacyLabelCreate(ctx, tracker, &label)
|
|
return &label, nil
|
|
}
|
|
|
|
func (r *mutationResolver) UpdateLabel(ctx context.Context, id int, input map[string]interface{}) (*model.Label, error) {
|
|
valid := valid.New(ctx).WithInput(input)
|
|
valid.OptionalString("foregroundColor", func(foreground string) {
|
|
_, err := parseColor(foreground)
|
|
valid.Expect(err == nil, err.Error()).WithField("foregroundColor")
|
|
})
|
|
valid.OptionalString("backgroundColor", func(background string) {
|
|
_, err := parseColor(background)
|
|
valid.Expect(err == nil, err.Error()).WithField("backgroundColor")
|
|
})
|
|
valid.OptionalString("name", func(name string) {
|
|
valid.Expect(len(name) != 0, "Name cannot be empty").WithField(name)
|
|
})
|
|
if !valid.Ok() {
|
|
return nil, nil
|
|
}
|
|
|
|
label, err := loaders.ForContext(ctx).LabelsByID.Load(id)
|
|
if err != nil || label == nil {
|
|
return nil, err
|
|
}
|
|
tracker, err := loaders.ForContext(ctx).TrackersByID.Load(label.TrackerID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if tracker.OwnerID != auth.ForContext(ctx).UserID {
|
|
return nil, fmt.Errorf("Access denied")
|
|
}
|
|
|
|
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
|
|
var err error
|
|
if len(input) != 0 {
|
|
_, err = database.Apply(label, input).
|
|
Where(database.WithAlias(label.Alias(), `id`)+"= ?", id).
|
|
RunWith(tx).
|
|
ExecContext(ctx)
|
|
}
|
|
return err
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
return label, nil
|
|
}
|
|
|
|
func (r *mutationResolver) DeleteLabel(ctx context.Context, id int) (*model.Label, error) {
|
|
var label model.Label
|
|
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
|
|
row := tx.QueryRowContext(ctx, `
|
|
DELETE FROM label
|
|
USING tracker
|
|
WHERE
|
|
label.tracker_id = tracker.id AND
|
|
tracker.owner_id = $1 AND
|
|
label.id = $2
|
|
RETURNING label.id, label.created, label.name, label.color,
|
|
label.text_color, label.tracker_id;
|
|
`, auth.ForContext(ctx).UserID, id)
|
|
return row.Scan(&label.ID, &label.Created, &label.Name,
|
|
&label.BackgroundColor, &label.ForegroundColor, &label.TrackerID)
|
|
}); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
webhooks.DeliverLegacyLabelDelete(ctx, label.TrackerID, label.ID)
|
|
return &label, nil
|
|
}
|
|
|
|
func (r *mutationResolver) SubmitTicket(ctx context.Context, trackerID int, input model.SubmitTicketInput) (*model.Ticket, error) {
|
|
valid := valid.New(ctx)
|
|
valid.Expect(len(input.Subject) <= 2048,
|
|
"Ticket subject must be fewer than to 2049 characters.").
|
|
WithField("subject")
|
|
if input.Body != nil {
|
|
valid.Expect(len(*input.Body) <= 16384,
|
|
"Ticket body must be less than 16 KiB in size").
|
|
WithField("body")
|
|
}
|
|
valid.Expect((input.ExternalID == nil) == (input.ExternalURL == nil),
|
|
"Must specify both externalId and externalUrl, or neither, but not one")
|
|
if !valid.Ok() {
|
|
return nil, nil
|
|
}
|
|
|
|
tracker, err := loaders.ForContext(ctx).TrackersByID.Load(trackerID)
|
|
if err != nil || tracker == nil {
|
|
return nil, err
|
|
}
|
|
|
|
owner, err := loaders.ForContext(ctx).UsersByID.Load(tracker.OwnerID)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
if !tracker.CanSubmit() {
|
|
return nil, fmt.Errorf("Access denied")
|
|
}
|
|
|
|
user := auth.ForContext(ctx)
|
|
if input.ExternalID != nil {
|
|
valid.Expect(tracker.OwnerID == user.UserID,
|
|
"Cannot configure external user import unless you are the owner of this tracker")
|
|
valid.Expect(strings.ContainsRune(*input.ExternalID, ':'),
|
|
"Format of externalId field is '<third-party>:<name>', .e.g 'example.org:jdoe'").
|
|
WithField("externalId")
|
|
u, err := url.Parse(*input.ExternalURL)
|
|
valid.Expect(err == nil, err.Error()).
|
|
And(u.Scheme == "http" || u.Scheme == "https", "Invalid URL scheme").
|
|
WithField("externalUrl")
|
|
}
|
|
if input.Created != nil {
|
|
valid.Expect(tracker.OwnerID == user.UserID,
|
|
"Cannot configure creation time unless you are the owner of this tracker").
|
|
WithField("created")
|
|
}
|
|
|
|
var ticket model.Ticket
|
|
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
|
|
var participant *model.Participant
|
|
if input.ExternalID != nil {
|
|
row := tx.QueryRowContext(ctx, `
|
|
INSERT INTO participant (
|
|
created, participant_type, external_id, external_url
|
|
) VALUES (
|
|
NOW() at time zone 'utc',
|
|
'external', $1, $2
|
|
)
|
|
ON CONFLICT ON CONSTRAINT participant_user_id_key
|
|
DO UPDATE SET created = participant.created
|
|
RETURNING id
|
|
`, *input.ExternalID, *input.ExternalURL)
|
|
participant := &model.Participant{}
|
|
if err := row.Scan(&participant.ID); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
var err error
|
|
participant, err = loaders.ForContext(ctx).
|
|
ParticipantsByUserID.Load(user.UserID)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
|
|
row := tx.QueryRowContext(ctx, `
|
|
WITH tr AS (
|
|
UPDATE tracker
|
|
SET
|
|
next_ticket_id = next_ticket_id + 1,
|
|
updated = NOW() at time zone 'utc'
|
|
WHERE id = $1
|
|
RETURNING id, next_ticket_id, name
|
|
) INSERT INTO ticket (
|
|
created, updated,
|
|
tracker_id, scoped_id,
|
|
submitter_id, title, description
|
|
) VALUES (
|
|
COALESCE($2, NOW() at time zone 'utc'),
|
|
NOW() at time zone 'utc',
|
|
(SELECT id FROM tr),
|
|
(SELECT next_ticket_id - 1 FROM tr),
|
|
$3, $4, $5
|
|
)
|
|
RETURNING
|
|
id, scoped_id, submitter_id, tracker_id, created, updated,
|
|
title, description, authenticity, status, resolution;`,
|
|
trackerID, input.Created, participant.ID, input.Subject, input.Body)
|
|
if err := row.Scan(&ticket.PKID, &ticket.ID, &ticket.SubmitterID,
|
|
&ticket.TrackerID, &ticket.Created, &ticket.Updated, &ticket.Subject,
|
|
&ticket.Body, &ticket.RawAuthenticity, &ticket.RawStatus,
|
|
&ticket.RawResolution); err != nil {
|
|
return err
|
|
}
|
|
|
|
ticket.OwnerName = owner.Username
|
|
ticket.TrackerName = tracker.Name
|
|
|
|
conf := config.ForContext(ctx)
|
|
origin := config.GetOrigin(conf, "todo.sr.ht", true)
|
|
|
|
builder := NewEventBuilder(ctx, tx, participant.ID, model.EVENT_CREATED).
|
|
WithTicket(tracker, &ticket)
|
|
|
|
if ticket.Body != nil {
|
|
mentions := ScanMentions(ctx, tracker, &ticket, *ticket.Body)
|
|
builder.AddMentions(&mentions)
|
|
}
|
|
|
|
builder.InsertSubscriptions()
|
|
|
|
var eventID int
|
|
row = tx.QueryRowContext(ctx, `
|
|
INSERT INTO event (
|
|
created, event_type, participant_id, ticket_id
|
|
) VALUES (
|
|
NOW() at time zone 'utc',
|
|
$1, $2, $3
|
|
) RETURNING id;
|
|
`, model.EVENT_CREATED, participant.ID, ticket.PKID)
|
|
if err := row.Scan(&eventID); err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
builder.InsertNotifications(eventID, nil)
|
|
|
|
details := NewTicketDetails{
|
|
Body: ticket.Body,
|
|
Root: origin,
|
|
TicketURL: fmt.Sprintf("/%s/%s/%d",
|
|
owner.CanonicalName(), tracker.Name, ticket.ID),
|
|
}
|
|
subject := fmt.Sprintf("%s: %s", ticket.Ref(), ticket.Subject)
|
|
builder.SendEmails(subject, newTicketTemplate, &details)
|
|
return nil
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
webhooks.DeliverLegacyTicketCreate(ctx, tracker, &ticket)
|
|
return &ticket, nil
|
|
}
|
|
|
|
func (r *mutationResolver) UpdateTicket(ctx context.Context, trackerID int, ticketID int, input map[string]interface{}) (*model.Ticket, error) {
|
|
if _, ok := input["import"]; ok {
|
|
panic(fmt.Errorf("not implemented")) // TODO
|
|
}
|
|
|
|
update := sq.Update("ticket").
|
|
PlaceholderFormat(sq.Dollar)
|
|
|
|
tracker, err := loaders.ForContext(ctx).TrackersByID.Load(trackerID)
|
|
if err != nil {
|
|
return nil, err
|
|
} else if tracker == nil {
|
|
return nil, nil
|
|
}
|
|
if !tracker.CanEdit() {
|
|
return nil, fmt.Errorf("Access denied")
|
|
}
|
|
|
|
ticket, err := loaders.ForContext(ctx).
|
|
TicketsByTrackerID.Load([2]int{trackerID, ticketID})
|
|
if err != nil {
|
|
return nil, err
|
|
} else if ticket == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
valid := valid.New(ctx).WithInput(input)
|
|
// TODO: Rename database columns title => subject; description => body
|
|
valid.OptionalString("subject", func(subject string) {
|
|
valid.Expect(len(subject) <= 2048,
|
|
"Ticket subject must be fewer than to 2049 characters.").
|
|
WithField("subject")
|
|
ticket.Subject = subject
|
|
update = update.Set("title", subject)
|
|
})
|
|
if body, ok := input["body"]; ok {
|
|
// TODO: Should we re-scan for new mentions? Probably yes
|
|
if ptr, ok := body.(*string); ok {
|
|
if ptr != nil {
|
|
valid.Expect(len(*ptr) <= 16384,
|
|
"Ticket body must be less than 16 KiB in size").
|
|
WithField("body")
|
|
}
|
|
ticket.Body = ptr
|
|
update = update.Set("description", ptr)
|
|
}
|
|
}
|
|
|
|
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
|
|
_, err := update.
|
|
Where(`ticket.id = ?`, ticket.PKID).
|
|
RunWith(tx).
|
|
ExecContext(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return ticket, nil
|
|
}
|
|
|
|
func (r *mutationResolver) UpdateTicketStatus(ctx context.Context, trackerID int, ticketID int, input model.UpdateStatusInput) (*model.Event, error) {
|
|
if input.Import != nil {
|
|
panic(fmt.Errorf("not implemented")) // TODO
|
|
}
|
|
|
|
tracker, err := loaders.ForContext(ctx).TrackersByID.Load(trackerID)
|
|
if err != nil {
|
|
return nil, err
|
|
} else if tracker == nil {
|
|
return nil, nil
|
|
}
|
|
if !tracker.CanTriage() {
|
|
return nil, fmt.Errorf("Access denied")
|
|
}
|
|
|
|
ticket, err := loaders.ForContext(ctx).
|
|
TicketsByTrackerID.Load([2]int{trackerID, ticketID})
|
|
if err != nil {
|
|
return nil, err
|
|
} else if ticket == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
owner, err := loaders.ForContext(ctx).UsersByID.Load(tracker.OwnerID)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
user := auth.ForContext(ctx)
|
|
part, err := loaders.ForContext(ctx).ParticipantsByUserID.Load(user.UserID)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
update := sq.Update("ticket").
|
|
PlaceholderFormat(sq.Dollar).
|
|
Set("status", input.Status.ToInt())
|
|
insert := sq.Insert("event").
|
|
PlaceholderFormat(sq.Dollar).
|
|
Columns("created", "event_type",
|
|
"ticket_id", "participant_id",
|
|
"old_status", "new_status",
|
|
"old_resolution", "new_resolution")
|
|
|
|
resolution := ticket.Resolution()
|
|
if input.Resolution != nil {
|
|
resolution = *input.Resolution
|
|
update = update.Set("resolution", resolution.ToInt())
|
|
} else if input.Status.ToInt() == model.STATUS_RESOLVED {
|
|
return nil, fmt.Errorf("resolution is required when status is RESOLVED")
|
|
}
|
|
|
|
var event model.Event
|
|
insert = insert.Values(sq.Expr("now() at time zone 'utc'"),
|
|
model.EVENT_STATUS_CHANGE, ticket.PKID, part.ID,
|
|
ticket.Status().ToInt(), input.Status.ToInt(),
|
|
ticket.Resolution().ToInt(), resolution.ToInt())
|
|
columns := database.Columns(ctx, &event)
|
|
|
|
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
|
|
_, err := update.
|
|
Where(`ticket.id = ?`, ticket.PKID).
|
|
RunWith(tx).
|
|
ExecContext(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = insert.
|
|
Suffix(`RETURNING ` + strings.Join(columns, ", ")).
|
|
RunWith(tx).
|
|
QueryRowContext(ctx).
|
|
Scan(database.Scan(ctx, &event)...)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
builder := NewEventBuilder(ctx, tx, part.ID, model.EVENT_STATUS_CHANGE).
|
|
WithTicket(tracker, ticket)
|
|
builder.InsertNotifications(event.ID, nil)
|
|
|
|
// Send notification emails
|
|
conf := config.ForContext(ctx)
|
|
origin := config.GetOrigin(conf, "todo.sr.ht", true)
|
|
details := TicketStatusDetails{
|
|
Root: origin,
|
|
TicketURL: fmt.Sprintf("/%s/%s/%d",
|
|
owner.CanonicalName(), tracker.Name, ticket.ID),
|
|
EventID: event.ID,
|
|
Status: input.Status.String(),
|
|
Resolution: resolution.String(),
|
|
}
|
|
subject := fmt.Sprintf("Re: %s: %s", ticket.Ref(), ticket.Subject)
|
|
builder.SendEmails(subject, ticketStatusTemplate, &details)
|
|
return nil
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
webhooks.DeliverLegacyEventCreate(ctx, tracker, ticket, &event)
|
|
return &event, nil
|
|
}
|
|
|
|
func (r *mutationResolver) SubmitComment(ctx context.Context, trackerID int, ticketID int, input model.SubmitCommentInput) (*model.Event, error) {
|
|
if input.Import != nil {
|
|
panic(fmt.Errorf("not implemented")) // TODO
|
|
}
|
|
|
|
tracker, err := loaders.ForContext(ctx).TrackersByID.Load(trackerID)
|
|
if err != nil {
|
|
return nil, err
|
|
} else if tracker == nil {
|
|
return nil, nil
|
|
}
|
|
if !tracker.CanComment() {
|
|
return nil, fmt.Errorf("Access denied")
|
|
}
|
|
if (input.Status != nil || input.Resolution != nil) && !tracker.CanTriage() {
|
|
return nil, fmt.Errorf("Access denied")
|
|
}
|
|
|
|
ticket, err := loaders.ForContext(ctx).
|
|
TicketsByTrackerID.Load([2]int{trackerID, ticketID})
|
|
if err != nil {
|
|
return nil, err
|
|
} else if ticket == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
owner, err := loaders.ForContext(ctx).UsersByID.Load(tracker.OwnerID)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
user := auth.ForContext(ctx)
|
|
part, err := loaders.ForContext(ctx).ParticipantsByUserID.Load(user.UserID)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
updateTicket := sq.Update("ticket").
|
|
PlaceholderFormat(sq.Dollar)
|
|
insertEvent := sq.Insert("event").
|
|
PlaceholderFormat(sq.Dollar).
|
|
Columns("created", "event_type",
|
|
"ticket_id", "participant_id", "comment_id",
|
|
"old_status", "new_status", "old_resolution", "new_resolution")
|
|
|
|
var (
|
|
oldStatus *int
|
|
_oldStatus int
|
|
newStatus *int
|
|
_newStatus int
|
|
oldResolution *int
|
|
_oldResolution int
|
|
newResolution *int
|
|
_newResolution int
|
|
eventType uint = model.EVENT_COMMENT
|
|
)
|
|
if input.Status != nil {
|
|
eventType |= model.EVENT_STATUS_CHANGE
|
|
oldStatus = &_oldStatus
|
|
newStatus = &_newStatus
|
|
oldResolution = &_oldResolution
|
|
newResolution = &_newResolution
|
|
*oldStatus = ticket.Status().ToInt()
|
|
*oldResolution = ticket.Resolution().ToInt()
|
|
*newStatus = input.Status.ToInt()
|
|
*newResolution = ticket.Resolution().ToInt()
|
|
updateTicket = updateTicket.Set("status", *newStatus)
|
|
if input.Resolution != nil {
|
|
*newResolution = input.Resolution.ToInt()
|
|
updateTicket = updateTicket.Set("resolution", *newResolution)
|
|
} else if input.Status.ToInt() == model.STATUS_RESOLVED {
|
|
return nil, fmt.Errorf("resolution is required when status is RESOLVED")
|
|
}
|
|
}
|
|
|
|
var event model.Event
|
|
columns := database.Columns(ctx, &event)
|
|
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
|
|
builder := NewEventBuilder(ctx, tx, part.ID, eventType).
|
|
WithTicket(tracker, ticket)
|
|
|
|
mentions := ScanMentions(ctx, tracker, ticket, input.Text)
|
|
builder.AddMentions(&mentions)
|
|
|
|
_, err := updateTicket.
|
|
Set(`comment_count`, sq.Expr(`comment_count + 1`)).
|
|
RunWith(tx).
|
|
ExecContext(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
row := tx.QueryRowContext(ctx, `
|
|
INSERT INTO ticket_comment (
|
|
created, updated, submitter_id, ticket_id, text
|
|
) VALUES (
|
|
NOW() at time zone 'utc',
|
|
NOW() at time zone 'utc',
|
|
$1, $2, $3
|
|
) RETURNING id;
|
|
`, part.ID, ticket.PKID, input.Text)
|
|
|
|
var commentID int
|
|
if err := row.Scan(&commentID); err != nil {
|
|
return err
|
|
}
|
|
|
|
builder.InsertSubscriptions()
|
|
|
|
eventRow := insertEvent.Values(sq.Expr("now() at time zone 'utc'"),
|
|
eventType, ticket.PKID, part.ID, commentID,
|
|
oldStatus, newStatus, oldResolution, newResolution).
|
|
Suffix(`RETURNING ` + strings.Join(columns, ", ")).
|
|
RunWith(tx).
|
|
QueryRowContext(ctx)
|
|
if err := eventRow.Scan(database.Scan(ctx, &event)...); err != nil {
|
|
return err
|
|
}
|
|
builder.InsertNotifications(event.ID, &commentID)
|
|
|
|
conf := config.ForContext(ctx)
|
|
origin := config.GetOrigin(conf, "todo.sr.ht", true)
|
|
details := SubmitCommentDetails{
|
|
Root: origin,
|
|
TicketURL: fmt.Sprintf("/%s/%s/%d",
|
|
owner.CanonicalName(), tracker.Name, ticket.ID),
|
|
EventID: event.ID,
|
|
Comment: input.Text,
|
|
StatusUpdated: input.Status != nil,
|
|
}
|
|
if details.StatusUpdated {
|
|
details.Status = model.TicketStatusFromInt(*newStatus).String()
|
|
details.Resolution = model.TicketResolutionFromInt(*newResolution).String()
|
|
}
|
|
subject := fmt.Sprintf("Re: %s: %s", ticket.Ref(), ticket.Subject)
|
|
builder.SendEmails(subject, submitCommentTemplate, &details)
|
|
return nil
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
webhooks.DeliverLegacyEventCreate(ctx, tracker, ticket, &event)
|
|
return &event, nil
|
|
}
|
|
|
|
func (r *mutationResolver) AssignUser(ctx context.Context, trackerID int, ticketID int, userID int) (*model.Event, error) {
|
|
tracker, err := loaders.ForContext(ctx).TrackersByID.Load(trackerID)
|
|
if err != nil {
|
|
return nil, err
|
|
} else if tracker == nil {
|
|
return nil, nil
|
|
}
|
|
if !tracker.CanTriage() {
|
|
return nil, fmt.Errorf("Access denied")
|
|
}
|
|
|
|
owner, err := loaders.ForContext(ctx).UsersByID.Load(tracker.OwnerID)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
ticket, err := loaders.ForContext(ctx).
|
|
TicketsByTrackerID.Load([2]int{trackerID, ticketID})
|
|
if err != nil {
|
|
return nil, err
|
|
} else if ticket == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
assignedUser, err := loaders.ForContext(ctx).UsersByID.Load(userID)
|
|
if err != nil {
|
|
return nil, err
|
|
} else if assignedUser == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
assignee, err := loaders.ForContext(ctx).ParticipantsByUserID.Load(userID)
|
|
if err != nil {
|
|
return nil, err
|
|
} else if assignee == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
user := auth.ForContext(ctx)
|
|
part, err := loaders.ForContext(ctx).ParticipantsByUserID.Load(user.UserID)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
var event model.Event
|
|
insertEvent := sq.Insert("event").
|
|
PlaceholderFormat(sq.Dollar).
|
|
Columns("created", "event_type", "ticket_id",
|
|
"participant_id", "by_participant_id").
|
|
Values(sq.Expr("now() at time zone 'utc'"),
|
|
model.EVENT_ASSIGNED_USER, ticket.PKID,
|
|
assignee.ID, part.ID)
|
|
|
|
valid := valid.New(ctx)
|
|
columns := database.Columns(ctx, &event)
|
|
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
|
|
_, err := tx.ExecContext(ctx, `
|
|
INSERT INTO ticket_assignee (
|
|
created, updated, ticket_id,
|
|
assignee_id, assigner_id
|
|
) VALUES (
|
|
NOW() at time zone 'utc',
|
|
NOW() at time zone 'utc',
|
|
$1, $2, $3
|
|
)`, ticket.PKID, userID, user.UserID)
|
|
if err, ok := err.(*pq.Error); ok &&
|
|
err.Code == "23505" && // unique_violation
|
|
err.Constraint == "idx_ticket_assignee_unique" {
|
|
valid.Error("This user is already assigned to this ticket").
|
|
WithField("userId")
|
|
return errors.New("placeholder") // To rollback the transaction
|
|
} else if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = tx.ExecContext(ctx, `
|
|
UPDATE ticket
|
|
SET updated = NOW() at time zone 'utc'
|
|
WHERE id = $1
|
|
`, ticket.PKID)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
row := insertEvent.
|
|
Suffix(`RETURNING ` + strings.Join(columns, ", ")).
|
|
RunWith(tx).
|
|
QueryRowContext(ctx)
|
|
if err := row.Scan(database.Scan(ctx, &event)...); err != nil {
|
|
return err
|
|
}
|
|
|
|
builder := NewEventBuilder(ctx, tx, part.ID, model.EVENT_ASSIGNED_USER).
|
|
WithTicket(tracker, ticket)
|
|
_, err = builder.tx.ExecContext(builder.ctx, `
|
|
INSERT INTO event_participant (
|
|
participant_id, event_type, subscribe
|
|
) VALUES (
|
|
$1, $2, true
|
|
);
|
|
`, assignee.ID, model.EVENT_ASSIGNED_USER)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
builder.InsertSubscriptions()
|
|
builder.InsertNotifications(event.ID, nil)
|
|
|
|
conf := config.ForContext(ctx)
|
|
origin := config.GetOrigin(conf, "todo.sr.ht", true)
|
|
details := TicketAssignedDetails{
|
|
Root: origin,
|
|
TicketURL: fmt.Sprintf("/%s/%s/%d",
|
|
owner.CanonicalName(), tracker.Name, ticket.ID),
|
|
EventID: event.ID,
|
|
Assigned: true,
|
|
Assigner: user.Username,
|
|
Assignee: assignedUser.Username,
|
|
}
|
|
subject := fmt.Sprintf("Re: %s: %s", ticket.Ref(), ticket.Subject)
|
|
builder.SendEmails(subject, ticketAssignedTemplate, &details)
|
|
return nil
|
|
}); err != nil {
|
|
if !valid.Ok() {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
webhooks.DeliverLegacyEventCreate(ctx, tracker, ticket, &event)
|
|
return &event, nil
|
|
}
|
|
|
|
func (r *mutationResolver) UnassignUser(ctx context.Context, trackerID int, ticketID int, userID int) (*model.Event, error) {
|
|
// XXX: I wonder how much of this can be shared with AssignUser
|
|
tracker, err := loaders.ForContext(ctx).TrackersByID.Load(trackerID)
|
|
if err != nil {
|
|
return nil, err
|
|
} else if tracker == nil {
|
|
return nil, nil
|
|
}
|
|
if !tracker.CanTriage() {
|
|
return nil, fmt.Errorf("Access denied")
|
|
}
|
|
|
|
owner, err := loaders.ForContext(ctx).UsersByID.Load(tracker.OwnerID)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
ticket, err := loaders.ForContext(ctx).
|
|
TicketsByTrackerID.Load([2]int{trackerID, ticketID})
|
|
if err != nil {
|
|
return nil, err
|
|
} else if ticket == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
assignedUser, err := loaders.ForContext(ctx).UsersByID.Load(userID)
|
|
if err != nil {
|
|
return nil, err
|
|
} else if assignedUser == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
assignee, err := loaders.ForContext(ctx).ParticipantsByUserID.Load(userID)
|
|
if err != nil {
|
|
return nil, err
|
|
} else if assignee == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
user := auth.ForContext(ctx)
|
|
part, err := loaders.ForContext(ctx).ParticipantsByUserID.Load(user.UserID)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
var event model.Event
|
|
insertEvent := sq.Insert("event").
|
|
PlaceholderFormat(sq.Dollar).
|
|
Columns("created", "event_type", "ticket_id",
|
|
"participant_id", "by_participant_id").
|
|
Values(sq.Expr("now() at time zone 'utc'"),
|
|
model.EVENT_UNASSIGNED_USER, ticket.PKID,
|
|
assignee.ID, part.ID)
|
|
|
|
valid := valid.New(ctx)
|
|
columns := database.Columns(ctx, &event)
|
|
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
|
|
var taId int
|
|
err := tx.QueryRowContext(ctx, `
|
|
DELETE FROM ticket_assignee
|
|
WHERE ticket_id = $1 AND assignee_id = $2
|
|
RETURNING id`,
|
|
ticket.PKID, userID).
|
|
Scan(&taId)
|
|
if err == sql.ErrNoRows {
|
|
valid.Error("This user is not assigned to this ticket").
|
|
WithField("userId")
|
|
return nil
|
|
} else if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = tx.ExecContext(ctx, `
|
|
UPDATE ticket
|
|
SET updated = NOW() at time zone 'utc'
|
|
WHERE id = $1
|
|
`, ticket.PKID)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
row := insertEvent.
|
|
Suffix(`RETURNING ` + strings.Join(columns, ", ")).
|
|
RunWith(tx).
|
|
QueryRowContext(ctx)
|
|
if err := row.Scan(database.Scan(ctx, &event)...); err != nil {
|
|
return err
|
|
}
|
|
|
|
builder := NewEventBuilder(ctx, tx, part.ID, model.EVENT_UNASSIGNED_USER).
|
|
WithTicket(tracker, ticket)
|
|
_, err = builder.tx.ExecContext(builder.ctx, `
|
|
INSERT INTO event_participant (
|
|
participant_id, event_type, subscribe
|
|
) VALUES (
|
|
$1, $2, true
|
|
);
|
|
`, assignee.ID, model.EVENT_UNASSIGNED_USER)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
builder.InsertSubscriptions()
|
|
builder.InsertNotifications(event.ID, nil)
|
|
|
|
conf := config.ForContext(ctx)
|
|
origin := config.GetOrigin(conf, "todo.sr.ht", true)
|
|
details := TicketAssignedDetails{
|
|
Root: origin,
|
|
TicketURL: fmt.Sprintf("/%s/%s/%d",
|
|
owner.CanonicalName(), tracker.Name, ticket.ID),
|
|
EventID: event.ID,
|
|
Assigned: false,
|
|
Assigner: user.Username,
|
|
Assignee: assignedUser.Username,
|
|
}
|
|
subject := fmt.Sprintf("Re: %s: %s", ticket.Ref(), ticket.Subject)
|
|
builder.SendEmails(subject, ticketAssignedTemplate, &details)
|
|
return nil
|
|
}); err != nil {
|
|
if !valid.Ok() {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
webhooks.DeliverLegacyEventCreate(ctx, tracker, ticket, &event)
|
|
return &event, nil
|
|
}
|
|
|
|
func (r *mutationResolver) LabelTicket(ctx context.Context, trackerID int, ticketID int, labelID int) (*model.Event, error) {
|
|
tracker, err := loaders.ForContext(ctx).TrackersByID.Load(trackerID)
|
|
if err != nil {
|
|
return nil, err
|
|
} else if tracker == nil {
|
|
return nil, fmt.Errorf("No such tracker")
|
|
}
|
|
if !tracker.CanTriage() {
|
|
return nil, fmt.Errorf("Access denied")
|
|
}
|
|
|
|
ticket, err := loaders.ForContext(ctx).
|
|
TicketsByTrackerID.Load([2]int{trackerID, ticketID})
|
|
if err != nil {
|
|
return nil, err
|
|
} else if ticket == nil {
|
|
return nil, fmt.Errorf("No such ticket")
|
|
}
|
|
|
|
label, err := loaders.ForContext(ctx).
|
|
LabelsByID.Load(labelID)
|
|
if err != nil {
|
|
return nil, err
|
|
} else if label == nil {
|
|
return nil, fmt.Errorf("No such label")
|
|
}
|
|
|
|
user := auth.ForContext(ctx)
|
|
part, err := loaders.ForContext(ctx).ParticipantsByUserID.Load(user.UserID)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
var event model.Event
|
|
insertEvent := sq.Insert("event").
|
|
PlaceholderFormat(sq.Dollar).
|
|
Columns("created", "event_type", "ticket_id",
|
|
"participant_id", "label_id").
|
|
Values(sq.Expr("now() at time zone 'utc'"),
|
|
model.EVENT_LABEL_ADDED, ticket.PKID, part.ID, label.ID)
|
|
|
|
valid := valid.New(ctx)
|
|
columns := database.Columns(ctx, &event)
|
|
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
|
|
_, err := tx.ExecContext(ctx, `
|
|
INSERT INTO ticket_label (
|
|
created, ticket_id, label_id, user_id
|
|
) VALUES (
|
|
NOW() at time zone 'utc',
|
|
$1, $2, $3
|
|
)`, ticket.PKID, label.ID, user.UserID)
|
|
if err, ok := err.(*pq.Error); ok &&
|
|
err.Code == "23505" && // unique_violation
|
|
err.Constraint == "ticket_label_pkey" {
|
|
valid.Error("This label is already assigned to this ticket").
|
|
WithField("userId")
|
|
return errors.New("placeholder") // To rollback the transaction
|
|
} else if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = tx.ExecContext(ctx, `
|
|
UPDATE ticket
|
|
SET updated = NOW() at time zone 'utc'
|
|
WHERE id = $1
|
|
`, ticket.PKID)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
row := insertEvent.
|
|
Suffix(`RETURNING ` + strings.Join(columns, ", ")).
|
|
RunWith(tx).
|
|
QueryRowContext(ctx)
|
|
if err := row.Scan(database.Scan(ctx, &event)...); err != nil {
|
|
return err
|
|
}
|
|
|
|
builder := NewEventBuilder(ctx, tx, part.ID, model.EVENT_LABEL_ADDED).
|
|
WithTicket(tracker, ticket)
|
|
builder.InsertNotifications(event.ID, nil)
|
|
return nil
|
|
}); err != nil {
|
|
if !valid.Ok() {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
webhooks.DeliverLegacyEventCreate(ctx, tracker, ticket, &event)
|
|
return &event, nil
|
|
}
|
|
|
|
func (r *mutationResolver) UnlabelTicket(ctx context.Context, trackerID int, ticketID int, labelID int) (*model.Event, error) {
|
|
// XXX: Some of this can be shared with labelTicket
|
|
tracker, err := loaders.ForContext(ctx).TrackersByID.Load(trackerID)
|
|
if err != nil {
|
|
return nil, err
|
|
} else if tracker == nil {
|
|
return nil, fmt.Errorf("No such tracker")
|
|
}
|
|
if !tracker.CanTriage() {
|
|
return nil, fmt.Errorf("Access denied")
|
|
}
|
|
|
|
ticket, err := loaders.ForContext(ctx).
|
|
TicketsByTrackerID.Load([2]int{trackerID, ticketID})
|
|
if err != nil {
|
|
return nil, err
|
|
} else if ticket == nil {
|
|
return nil, fmt.Errorf("No such ticket")
|
|
}
|
|
|
|
label, err := loaders.ForContext(ctx).
|
|
LabelsByID.Load(labelID)
|
|
if err != nil {
|
|
return nil, err
|
|
} else if label == nil {
|
|
return nil, fmt.Errorf("No such label")
|
|
}
|
|
|
|
user := auth.ForContext(ctx)
|
|
part, err := loaders.ForContext(ctx).ParticipantsByUserID.Load(user.UserID)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
var event model.Event
|
|
insertEvent := sq.Insert("event").
|
|
PlaceholderFormat(sq.Dollar).
|
|
Columns("created", "event_type", "ticket_id",
|
|
"participant_id", "label_id").
|
|
Values(sq.Expr("now() at time zone 'utc'"),
|
|
model.EVENT_LABEL_REMOVED, ticket.PKID, part.ID, label.ID)
|
|
|
|
columns := database.Columns(ctx, &event)
|
|
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
|
|
_, err := tx.ExecContext(ctx, `
|
|
DELETE FROM ticket_label
|
|
WHERE ticket_id = $1 AND label_id = $2`,
|
|
ticket.PKID, label.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = tx.ExecContext(ctx, `
|
|
UPDATE ticket
|
|
SET updated = NOW() at time zone 'utc'
|
|
WHERE id = $1
|
|
`, ticket.PKID)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
row := insertEvent.
|
|
Suffix(`RETURNING ` + strings.Join(columns, ", ")).
|
|
RunWith(tx).
|
|
QueryRowContext(ctx)
|
|
if err := row.Scan(database.Scan(ctx, &event)...); err != nil {
|
|
return err
|
|
}
|
|
|
|
builder := NewEventBuilder(ctx, tx, part.ID, model.EVENT_LABEL_REMOVED).
|
|
WithTicket(tracker, ticket)
|
|
builder.InsertNotifications(event.ID, nil)
|
|
return nil
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
webhooks.DeliverLegacyEventCreate(ctx, tracker, ticket, &event)
|
|
return &event, nil
|
|
}
|
|
|
|
func (r *queryResolver) Version(ctx context.Context) (*model.Version, error) {
|
|
return &model.Version{
|
|
Major: 0,
|
|
Minor: 0,
|
|
Patch: 0,
|
|
DeprecationDate: nil,
|
|
}, nil
|
|
}
|
|
|
|
func (r *queryResolver) Me(ctx context.Context) (*model.User, error) {
|
|
user := auth.ForContext(ctx)
|
|
return &model.User{
|
|
ID: user.UserID,
|
|
Created: user.Created,
|
|
Updated: user.Updated,
|
|
Username: user.Username,
|
|
Email: user.Email,
|
|
URL: user.URL,
|
|
Location: user.Location,
|
|
Bio: user.Bio,
|
|
}, nil
|
|
}
|
|
|
|
func (r *queryResolver) User(ctx context.Context, username string) (*model.User, error) {
|
|
return loaders.ForContext(ctx).UsersByName.Load(username)
|
|
}
|
|
|
|
func (r *queryResolver) Trackers(ctx context.Context, cursor *coremodel.Cursor) (*model.TrackerCursor, error) {
|
|
if cursor == nil {
|
|
cursor = coremodel.NewCursor(nil)
|
|
}
|
|
|
|
var trackers []*model.Tracker
|
|
if err := database.WithTx(ctx, &sql.TxOptions{
|
|
Isolation: 0,
|
|
ReadOnly: true,
|
|
}, func(tx *sql.Tx) error {
|
|
tracker := (&model.Tracker{}).As(`tr`)
|
|
query := database.
|
|
Select(ctx, tracker).
|
|
From(`tracker tr`).
|
|
Where(`tr.owner_id = ?`, auth.ForContext(ctx).UserID)
|
|
trackers, cursor = tracker.QueryWithCursor(ctx, tx, query, cursor)
|
|
return nil
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &model.TrackerCursor{trackers, cursor}, nil
|
|
}
|
|
|
|
func (r *queryResolver) Tracker(ctx context.Context, id int) (*model.Tracker, error) {
|
|
return loaders.ForContext(ctx).TrackersByID.Load(id)
|
|
}
|
|
|
|
func (r *queryResolver) TrackerByName(ctx context.Context, name string) (*model.Tracker, error) {
|
|
return loaders.ForContext(ctx).TrackersByName.Load(name)
|
|
}
|
|
|
|
func (r *queryResolver) TrackerByOwner(ctx context.Context, owner string, tracker string) (*model.Tracker, error) {
|
|
if strings.HasPrefix(owner, "~") {
|
|
owner = owner[1:]
|
|
} else {
|
|
return nil, fmt.Errorf("Expected owner to be a canonical name")
|
|
}
|
|
return loaders.ForContext(ctx).TrackersByOwnerName.Load([2]string{owner, tracker})
|
|
}
|
|
|
|
func (r *queryResolver) Events(ctx context.Context, cursor *coremodel.Cursor) (*model.EventCursor, error) {
|
|
if cursor == nil {
|
|
cursor = coremodel.NewCursor(nil)
|
|
}
|
|
|
|
var events []*model.Event
|
|
if err := database.WithTx(ctx, &sql.TxOptions{}, func(tx *sql.Tx) error {
|
|
event := (&model.Event{}).As(`ev`)
|
|
query := database.
|
|
Select(ctx, event).
|
|
From(`event ev`).
|
|
Join(`participant p ON p.user_id = ev.participant_id`).
|
|
Where(`p.user_id = ?`, auth.ForContext(ctx).UserID)
|
|
events, cursor = event.QueryWithCursor(ctx, tx, query, cursor)
|
|
return nil
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &model.EventCursor{events, cursor}, nil
|
|
}
|
|
|
|
func (r *queryResolver) Subscriptions(ctx context.Context, cursor *coremodel.Cursor) (*model.ActivitySubscriptionCursor, error) {
|
|
if cursor == nil {
|
|
cursor = coremodel.NewCursor(nil)
|
|
}
|
|
|
|
var subs []model.ActivitySubscription
|
|
if err := database.WithTx(ctx, &sql.TxOptions{}, func(tx *sql.Tx) error {
|
|
sub := (&model.SubscriptionInfo{}).As(`sub`)
|
|
query := database.
|
|
Select(ctx, sub).
|
|
From(`ticket_subscription sub`).
|
|
Join(`participant p ON p.id = sub.participant_id`).
|
|
Where(`p.user_id = ?`, auth.ForContext(ctx).UserID)
|
|
subs, cursor = sub.QueryWithCursor(ctx, tx, query, cursor)
|
|
return nil
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &model.ActivitySubscriptionCursor{subs, cursor}, nil
|
|
}
|
|
|
|
func (r *statusChangeResolver) Ticket(ctx context.Context, obj *model.StatusChange) (*model.Ticket, error) {
|
|
return loaders.ForContext(ctx).TicketsByID.Load(obj.TicketID)
|
|
}
|
|
|
|
func (r *statusChangeResolver) Editor(ctx context.Context, obj *model.StatusChange) (model.Entity, error) {
|
|
return loaders.ForContext(ctx).EntitiesByParticipantID.Load(obj.ParticipantID)
|
|
}
|
|
|
|
func (r *ticketResolver) Submitter(ctx context.Context, obj *model.Ticket) (model.Entity, error) {
|
|
return loaders.ForContext(ctx).EntitiesByParticipantID.Load(obj.SubmitterID)
|
|
}
|
|
|
|
func (r *ticketResolver) Tracker(ctx context.Context, obj *model.Ticket) (*model.Tracker, error) {
|
|
return loaders.ForContext(ctx).TrackersByID.Load(obj.TrackerID)
|
|
}
|
|
|
|
func (r *ticketResolver) Labels(ctx context.Context, obj *model.Ticket) ([]*model.Label, error) {
|
|
var labels []*model.Label
|
|
if err := database.WithTx(ctx, &sql.TxOptions{
|
|
Isolation: 0,
|
|
ReadOnly: true,
|
|
}, func(tx *sql.Tx) error {
|
|
var (
|
|
err error
|
|
rows *sql.Rows
|
|
)
|
|
label := (&model.Label{}).As(`l`)
|
|
query := database.
|
|
Select(ctx, label).
|
|
From(`label l`).
|
|
Join(`ticket_label tl ON tl.label_id = l.id`).
|
|
Where(`tl.ticket_id = ?`, obj.PKID)
|
|
if rows, err = query.RunWith(tx).QueryContext(ctx); err != nil {
|
|
panic(err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var label model.Label
|
|
if err := rows.Scan(database.Scan(ctx, &label)...); err != nil {
|
|
panic(err)
|
|
}
|
|
labels = append(labels, &label)
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return labels, nil
|
|
}
|
|
|
|
func (r *ticketResolver) Assignees(ctx context.Context, obj *model.Ticket) ([]model.Entity, error) {
|
|
var entities []model.Entity
|
|
if err := database.WithTx(ctx, &sql.TxOptions{
|
|
Isolation: 0,
|
|
ReadOnly: true,
|
|
}, func(tx *sql.Tx) error {
|
|
var (
|
|
err error
|
|
rows *sql.Rows
|
|
)
|
|
user := (&model.User{}).As(`u`)
|
|
query := database.
|
|
Select(ctx, user).
|
|
From(`ticket_assignee ta`).
|
|
Join(`"user" u ON ta.assignee_id = u.id`).
|
|
Where(`ta.ticket_id = ?`, obj.PKID)
|
|
if rows, err = query.RunWith(tx).QueryContext(ctx); err != nil {
|
|
panic(err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var user model.User
|
|
if err := rows.Scan(database.Scan(ctx, &user)...); err != nil {
|
|
panic(err)
|
|
}
|
|
entities = append(entities, &user)
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return entities, nil
|
|
}
|
|
|
|
func (r *ticketResolver) Events(ctx context.Context, obj *model.Ticket, cursor *coremodel.Cursor) (*model.EventCursor, error) {
|
|
if cursor == nil {
|
|
cursor = coremodel.NewCursor(nil)
|
|
}
|
|
|
|
var events []*model.Event
|
|
if err := database.WithTx(ctx, &sql.TxOptions{}, func(tx *sql.Tx) error {
|
|
event := (&model.Event{}).As(`ev`)
|
|
query := database.
|
|
Select(ctx, event).
|
|
From(`event ev`).
|
|
Where(`ev.ticket_id = ?`, obj.PKID)
|
|
events, cursor = event.QueryWithCursor(ctx, tx, query, cursor)
|
|
return nil
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &model.EventCursor{events, cursor}, nil
|
|
}
|
|
|
|
func (r *ticketResolver) Subscription(ctx context.Context, obj *model.Ticket) (*model.TicketSubscription, error) {
|
|
// Regarding unsafe: if they have access to this ticket resource, they were
|
|
// already authenticated for it.
|
|
return loaders.ForContext(ctx).SubsByTicketIDUnsafe.Load(obj.PKID)
|
|
}
|
|
|
|
func (r *ticketMentionResolver) Ticket(ctx context.Context, obj *model.TicketMention) (*model.Ticket, error) {
|
|
return loaders.ForContext(ctx).TicketsByID.Load(obj.TicketID)
|
|
}
|
|
|
|
func (r *ticketMentionResolver) Author(ctx context.Context, obj *model.TicketMention) (model.Entity, error) {
|
|
return loaders.ForContext(ctx).EntitiesByParticipantID.Load(obj.ParticipantID)
|
|
}
|
|
|
|
func (r *ticketMentionResolver) Mentioned(ctx context.Context, obj *model.TicketMention) (*model.Ticket, error) {
|
|
return loaders.ForContext(ctx).TicketsByID.Load(obj.MentionedID)
|
|
}
|
|
|
|
func (r *ticketSubscriptionResolver) Ticket(ctx context.Context, obj *model.TicketSubscription) (*model.Ticket, error) {
|
|
return loaders.ForContext(ctx).TicketsByID.Load(obj.TicketID)
|
|
}
|
|
|
|
func (r *trackerResolver) Owner(ctx context.Context, obj *model.Tracker) (model.Entity, error) {
|
|
return loaders.ForContext(ctx).UsersByID.Load(obj.OwnerID)
|
|
}
|
|
|
|
func (r *trackerResolver) Ticket(ctx context.Context, obj *model.Tracker, id int) (*model.Ticket, error) {
|
|
return loaders.ForContext(ctx).TicketsByTrackerID.Load([2]int{obj.ID, id})
|
|
}
|
|
|
|
func (r *trackerResolver) Tickets(ctx context.Context, obj *model.Tracker, cursor *coremodel.Cursor) (*model.TicketCursor, error) {
|
|
if cursor == nil {
|
|
cursor = coremodel.NewCursor(nil)
|
|
}
|
|
|
|
var tickets []*model.Ticket
|
|
if err := database.WithTx(ctx, &sql.TxOptions{
|
|
Isolation: 0,
|
|
ReadOnly: true,
|
|
}, func(tx *sql.Tx) error {
|
|
ticket := (&model.Ticket{}).As(`tk`)
|
|
var query sq.SelectBuilder
|
|
if obj.CanBrowse() {
|
|
query = database.
|
|
Select(ctx, ticket).
|
|
From(`ticket tk`).
|
|
Where(`tk.tracker_id = ?`, obj.ID)
|
|
} else {
|
|
user := auth.ForContext(ctx)
|
|
query = database.
|
|
Select(ctx, ticket).
|
|
From(`ticket tk`).
|
|
Join(`participant p ON p.user_id = ?`, user.UserID).
|
|
Where(sq.And{
|
|
sq.Expr(`tk.tracker_id = ?`, obj.ID),
|
|
sq.Expr(`tk.submitter_id = p.id`),
|
|
})
|
|
}
|
|
tickets, cursor = ticket.QueryWithCursor(ctx, tx, query, cursor)
|
|
return nil
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &model.TicketCursor{tickets, cursor}, nil
|
|
}
|
|
|
|
func (r *trackerResolver) Labels(ctx context.Context, obj *model.Tracker, cursor *coremodel.Cursor) (*model.LabelCursor, error) {
|
|
if cursor == nil {
|
|
cursor = coremodel.NewCursor(nil)
|
|
}
|
|
|
|
var labels []*model.Label
|
|
if err := database.WithTx(ctx, &sql.TxOptions{
|
|
Isolation: 0,
|
|
ReadOnly: true,
|
|
}, func(tx *sql.Tx) error {
|
|
label := (&model.Label{}).As(`l`)
|
|
query := database.
|
|
Select(ctx, label).
|
|
From(`label l`).
|
|
Where(`l.tracker_id = ?`, obj.ID)
|
|
labels, cursor = label.QueryWithCursor(ctx, tx, query, cursor)
|
|
return nil
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &model.LabelCursor{labels, cursor}, nil
|
|
}
|
|
|
|
func (r *trackerResolver) Subscription(ctx context.Context, obj *model.Tracker) (*model.TrackerSubscription, error) {
|
|
// Regarding unsafe: if they have access to this tracker resource, they
|
|
// were already authenticated for it.
|
|
return loaders.ForContext(ctx).SubsByTrackerIDUnsafe.Load(obj.ID)
|
|
}
|
|
|
|
func (r *trackerResolver) ACL(ctx context.Context, obj *model.Tracker) (model.ACL, error) {
|
|
if obj.ACLID == nil {
|
|
return &model.DefaultACL{
|
|
Browse: obj.CanBrowse(),
|
|
Submit: obj.CanSubmit(),
|
|
Comment: obj.CanComment(),
|
|
Edit: obj.CanEdit(),
|
|
Triage: obj.CanTriage(),
|
|
}, nil
|
|
}
|
|
|
|
acl := (&model.TrackerACL{}).As(`ua`)
|
|
if err := database.WithTx(ctx, &sql.TxOptions{
|
|
Isolation: 0,
|
|
ReadOnly: true,
|
|
}, func(tx *sql.Tx) error {
|
|
var access int
|
|
query := database.
|
|
Select(ctx, acl).
|
|
Column(`ua.permissions`).
|
|
From(`user_access ua`).
|
|
Where(`ua.id = ?`, *obj.ACLID)
|
|
row := query.RunWith(tx).QueryRowContext(ctx)
|
|
if err := row.Scan(append(database.Scan(ctx, acl),
|
|
&access)...); err != nil {
|
|
return err
|
|
}
|
|
acl.Browse = access&model.ACCESS_BROWSE != 0
|
|
acl.Submit = access&model.ACCESS_SUBMIT != 0
|
|
acl.Comment = access&model.ACCESS_COMMENT != 0
|
|
acl.Edit = access&model.ACCESS_EDIT != 0
|
|
acl.Triage = access&model.ACCESS_TRIAGE != 0
|
|
return nil
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return acl, nil
|
|
}
|
|
|
|
func (r *trackerResolver) DefaultACL(ctx context.Context, obj *model.Tracker) (*model.DefaultACL, error) {
|
|
acl := &model.DefaultACL{}
|
|
acl.SetBits(obj.DefaultAccess)
|
|
return acl, nil
|
|
}
|
|
|
|
func (r *trackerResolver) Acls(ctx context.Context, obj *model.Tracker, cursor *coremodel.Cursor) (*model.ACLCursor, error) {
|
|
if obj.OwnerID != auth.ForContext(ctx).UserID {
|
|
return nil, errors.New("Access denied")
|
|
}
|
|
|
|
if cursor == nil {
|
|
cursor = coremodel.NewCursor(nil)
|
|
}
|
|
|
|
var acls []*model.TrackerACL
|
|
if err := database.WithTx(ctx, &sql.TxOptions{
|
|
Isolation: 0,
|
|
ReadOnly: true,
|
|
}, func(tx *sql.Tx) error {
|
|
acl := (&model.TrackerACL{}).As(`ua`)
|
|
query := database.
|
|
Select(ctx, acl).
|
|
From(`user_access ua`).
|
|
Where(`ua.tracker_id = ?`, obj.ID)
|
|
acls, cursor = acl.QueryWithCursor(ctx, tx, query, cursor)
|
|
return nil
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &model.ACLCursor{acls, cursor}, nil
|
|
}
|
|
|
|
func (r *trackerResolver) Export(ctx context.Context, obj *model.Tracker) (string, error) {
|
|
panic(fmt.Errorf("not implemented")) // TODO
|
|
}
|
|
|
|
func (r *trackerACLResolver) Tracker(ctx context.Context, obj *model.TrackerACL) (*model.Tracker, error) {
|
|
return loaders.ForContext(ctx).TrackersByID.Load(obj.TrackerID)
|
|
}
|
|
|
|
func (r *trackerACLResolver) Entity(ctx context.Context, obj *model.TrackerACL) (model.Entity, error) {
|
|
return loaders.ForContext(ctx).UsersByID.Load(obj.UserID)
|
|
}
|
|
|
|
func (r *trackerSubscriptionResolver) Tracker(ctx context.Context, obj *model.TrackerSubscription) (*model.Tracker, error) {
|
|
return loaders.ForContext(ctx).TrackersByID.Load(obj.TrackerID)
|
|
}
|
|
|
|
func (r *userResolver) Trackers(ctx context.Context, obj *model.User, cursor *coremodel.Cursor) (*model.TrackerCursor, error) {
|
|
if cursor == nil {
|
|
cursor = coremodel.NewCursor(nil)
|
|
}
|
|
|
|
var trackers []*model.Tracker
|
|
if err := database.WithTx(ctx, &sql.TxOptions{
|
|
Isolation: 0,
|
|
ReadOnly: true,
|
|
}, func(tx *sql.Tx) error {
|
|
tracker := (&model.Tracker{}).As(`tr`)
|
|
auser := auth.ForContext(ctx)
|
|
query := database.
|
|
Select(ctx, tracker).
|
|
From(`tracker tr`).
|
|
LeftJoin(`user_access ua ON ua.tracker_id = tr.id`).
|
|
Where(sq.And{
|
|
sq.Expr(`tr.owner_id = ?`, obj.ID),
|
|
sq.Or{
|
|
sq.Expr(`tr.owner_id = ?`, auser.UserID),
|
|
sq.Expr(`tr.visibility = 'PUBLIC'`),
|
|
sq.And{
|
|
sq.Expr(`ua.user_id = ?`, auser.UserID),
|
|
sq.Expr(`ua.permissions > 0`),
|
|
},
|
|
},
|
|
})
|
|
trackers, cursor = tracker.QueryWithCursor(ctx, tx, query, cursor)
|
|
return nil
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &model.TrackerCursor{trackers, cursor}, nil
|
|
}
|
|
|
|
func (r *userMentionResolver) Ticket(ctx context.Context, obj *model.UserMention) (*model.Ticket, error) {
|
|
return loaders.ForContext(ctx).TicketsByID.Load(obj.TicketID)
|
|
}
|
|
|
|
func (r *userMentionResolver) Author(ctx context.Context, obj *model.UserMention) (model.Entity, error) {
|
|
return loaders.ForContext(ctx).EntitiesByParticipantID.Load(obj.ParticipantID)
|
|
}
|
|
|
|
func (r *userMentionResolver) Mentioned(ctx context.Context, obj *model.UserMention) (model.Entity, error) {
|
|
return loaders.ForContext(ctx).EntitiesByParticipantID.Load(obj.MentionedID)
|
|
}
|
|
|
|
// Assignment returns api.AssignmentResolver implementation.
|
|
func (r *Resolver) Assignment() api.AssignmentResolver { return &assignmentResolver{r} }
|
|
|
|
// Comment returns api.CommentResolver implementation.
|
|
func (r *Resolver) Comment() api.CommentResolver { return &commentResolver{r} }
|
|
|
|
// Created returns api.CreatedResolver implementation.
|
|
func (r *Resolver) Created() api.CreatedResolver { return &createdResolver{r} }
|
|
|
|
// Event returns api.EventResolver implementation.
|
|
func (r *Resolver) Event() api.EventResolver { return &eventResolver{r} }
|
|
|
|
// Label returns api.LabelResolver implementation.
|
|
func (r *Resolver) Label() api.LabelResolver { return &labelResolver{r} }
|
|
|
|
// LabelUpdate returns api.LabelUpdateResolver implementation.
|
|
func (r *Resolver) LabelUpdate() api.LabelUpdateResolver { return &labelUpdateResolver{r} }
|
|
|
|
// Mutation returns api.MutationResolver implementation.
|
|
func (r *Resolver) Mutation() api.MutationResolver { return &mutationResolver{r} }
|
|
|
|
// Query returns api.QueryResolver implementation.
|
|
func (r *Resolver) Query() api.QueryResolver { return &queryResolver{r} }
|
|
|
|
// StatusChange returns api.StatusChangeResolver implementation.
|
|
func (r *Resolver) StatusChange() api.StatusChangeResolver { return &statusChangeResolver{r} }
|
|
|
|
// Ticket returns api.TicketResolver implementation.
|
|
func (r *Resolver) Ticket() api.TicketResolver { return &ticketResolver{r} }
|
|
|
|
// TicketMention returns api.TicketMentionResolver implementation.
|
|
func (r *Resolver) TicketMention() api.TicketMentionResolver { return &ticketMentionResolver{r} }
|
|
|
|
// TicketSubscription returns api.TicketSubscriptionResolver implementation.
|
|
func (r *Resolver) TicketSubscription() api.TicketSubscriptionResolver {
|
|
return &ticketSubscriptionResolver{r}
|
|
}
|
|
|
|
// Tracker returns api.TrackerResolver implementation.
|
|
func (r *Resolver) Tracker() api.TrackerResolver { return &trackerResolver{r} }
|
|
|
|
// TrackerACL returns api.TrackerACLResolver implementation.
|
|
func (r *Resolver) TrackerACL() api.TrackerACLResolver { return &trackerACLResolver{r} }
|
|
|
|
// TrackerSubscription returns api.TrackerSubscriptionResolver implementation.
|
|
func (r *Resolver) TrackerSubscription() api.TrackerSubscriptionResolver {
|
|
return &trackerSubscriptionResolver{r}
|
|
}
|
|
|
|
// User returns api.UserResolver implementation.
|
|
func (r *Resolver) User() api.UserResolver { return &userResolver{r} }
|
|
|
|
// UserMention returns api.UserMentionResolver implementation.
|
|
func (r *Resolver) UserMention() api.UserMentionResolver { return &userMentionResolver{r} }
|
|
|
|
type assignmentResolver struct{ *Resolver }
|
|
type commentResolver struct{ *Resolver }
|
|
type createdResolver struct{ *Resolver }
|
|
type eventResolver struct{ *Resolver }
|
|
type labelResolver struct{ *Resolver }
|
|
type labelUpdateResolver struct{ *Resolver }
|
|
type mutationResolver struct{ *Resolver }
|
|
type queryResolver struct{ *Resolver }
|
|
type statusChangeResolver struct{ *Resolver }
|
|
type ticketResolver struct{ *Resolver }
|
|
type ticketMentionResolver struct{ *Resolver }
|
|
type ticketSubscriptionResolver struct{ *Resolver }
|
|
type trackerResolver struct{ *Resolver }
|
|
type trackerACLResolver struct{ *Resolver }
|
|
type trackerSubscriptionResolver struct{ *Resolver }
|
|
type userResolver struct{ *Resolver }
|
|
type userMentionResolver struct{ *Resolver }
|