mirror of
https://github.com/warmbly/warmbly.git
synced 2026-08-25 08:00:37 +00:00
392 lines
9.7 KiB
Go
392 lines
9.7 KiB
Go
package repository
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/warmbly/warmbly/internal/config"
|
|
"github.com/warmbly/warmbly/internal/errx"
|
|
"github.com/warmbly/warmbly/internal/infrastructure/db"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
"github.com/warmbly/warmbly/internal/pkg/encrypt"
|
|
)
|
|
|
|
type SequenceRepository interface {
|
|
Create(ctx context.Context, userID, campaignID string) (*models.Sequence, *errx.Error)
|
|
Get(ctx context.Context, userID, campaignID string) ([]models.Sequence, *errx.Error)
|
|
Update(ctx context.Context, userID, campaignID, sequenceID string, data *models.UpdateSequence) (*models.Sequence, *errx.Error)
|
|
// UpdateLayout merges only x/y for the given steps under a campaign, in one
|
|
// statement, without bumping updated_at — so a drag never reads as a content
|
|
// change. Cosmetic and unaudited.
|
|
UpdateLayout(ctx context.Context, userID, campaignID string, positions []models.SequencePosition) *errx.Error
|
|
Delete(ctx context.Context, userID, campaignID, sequenceID string) *errx.Error
|
|
}
|
|
|
|
type sequenceRepository struct {
|
|
DB *db.DB
|
|
Encrypt *encrypt.Encrypter
|
|
}
|
|
|
|
func NewSequenceRepostory(db *db.DB) SequenceRepository {
|
|
return &sequenceRepository{
|
|
DB: db,
|
|
}
|
|
}
|
|
|
|
var SequenceSelections []string = []string{
|
|
"id",
|
|
"name",
|
|
"subject",
|
|
"body_plain",
|
|
"body_html",
|
|
"body_sync",
|
|
"body_code",
|
|
"wait_after",
|
|
"position",
|
|
"x",
|
|
"y",
|
|
"conditions",
|
|
"kind",
|
|
"action",
|
|
"updated_at",
|
|
"created_at",
|
|
}
|
|
|
|
func getSequenceSelect(join bool) string {
|
|
sel := SequenceSelections
|
|
if join {
|
|
for i := range sel {
|
|
sel[i] = "s." + sel[i]
|
|
}
|
|
}
|
|
return strings.Join(sel, ", ")
|
|
}
|
|
|
|
var (
|
|
SequenceSelect = getSequenceSelect(false)
|
|
SequenceSelectJoin = getSequenceSelect(true)
|
|
)
|
|
|
|
func GetSequence(row db.Scannable, seq *models.Sequence) error {
|
|
return row.Scan(
|
|
&seq.ID, &seq.Name, &seq.Subject, &seq.BodyPlain, &seq.BodyHTML, &seq.BodySync,
|
|
&seq.BodyCode, &seq.WaitAfter, &seq.Position, &seq.X, &seq.Y, &seq.Conditions, &seq.Kind, &seq.Action,
|
|
&seq.UpdatedAt, &seq.CreatedAt,
|
|
)
|
|
}
|
|
|
|
func (r *sequenceRepository) Get(ctx context.Context, userID string, campaignID string) ([]models.Sequence, *errx.Error) {
|
|
query := fmt.Sprintf(
|
|
`SELECT %s
|
|
FROM sequences s
|
|
JOIN campaigns c ON s.campaign_id = c.id
|
|
WHERE s.campaign_id = $1
|
|
AND c.user_id = $2
|
|
ORDER BY s.position ASC, s.created_at ASC`,
|
|
SequenceSelectJoin,
|
|
)
|
|
|
|
params := []any{
|
|
campaignID,
|
|
userID,
|
|
}
|
|
|
|
rows, err := r.DB.Query(
|
|
ctx,
|
|
query,
|
|
params...,
|
|
)
|
|
if err != nil {
|
|
db.CaptureError(err, query, params, "query")
|
|
return nil, errx.InternalError()
|
|
}
|
|
|
|
var sequences []models.Sequence = make([]models.Sequence, 0)
|
|
|
|
for rows.Next() {
|
|
var seq models.Sequence
|
|
err = GetSequence(rows, &seq)
|
|
if err != nil {
|
|
db.CaptureError(err, "", nil, "scan")
|
|
return nil, errx.InternalError()
|
|
}
|
|
sequences = append(sequences, seq)
|
|
}
|
|
|
|
return sequences, nil
|
|
}
|
|
|
|
func (r *sequenceRepository) Create(ctx context.Context, userID string, campaignID string) (*models.Sequence, *errx.Error) {
|
|
tx, err := r.DB.Begin(ctx)
|
|
if err != nil {
|
|
db.CaptureError(err, "", nil, "begin")
|
|
return nil, errx.InternalError()
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
query := `
|
|
SELECT user_id
|
|
FROM campaigns WHERE id = $1
|
|
`
|
|
|
|
params := []any{
|
|
campaignID,
|
|
}
|
|
|
|
var ownerID string
|
|
err = tx.QueryRow(
|
|
ctx,
|
|
query,
|
|
params...,
|
|
).Scan(&ownerID)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, errx.ErrNotFound
|
|
}
|
|
db.CaptureError(err, query, params, "queryrow")
|
|
return nil, errx.InternalError()
|
|
}
|
|
|
|
if ownerID != userID {
|
|
return nil, errx.ErrForbidden
|
|
}
|
|
|
|
// Get the next position for this campaign's sequences
|
|
var nextPos int
|
|
_ = tx.QueryRow(ctx, `SELECT COALESCE(MAX(position), 0) + 1 FROM sequences WHERE campaign_id = $1`, campaignID).Scan(&nextPos)
|
|
|
|
query = fmt.Sprintf(
|
|
`INSERT INTO sequences (
|
|
campaign_id, name, subject, body_plain, body_html, position
|
|
) VALUES (
|
|
$1, $2, $3, $4, $5, $6
|
|
) RETURNING %s`, SequenceSelect,
|
|
)
|
|
|
|
params = []any{
|
|
campaignID,
|
|
config.SequenceDefaultName,
|
|
"",
|
|
"",
|
|
"<div></div>",
|
|
nextPos,
|
|
}
|
|
|
|
row := tx.QueryRow(
|
|
ctx,
|
|
query,
|
|
params...,
|
|
)
|
|
var seq models.Sequence
|
|
err = GetSequence(row, &seq)
|
|
if err != nil {
|
|
db.CaptureError(err, query, params, "scan")
|
|
return nil, errx.InternalError()
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
db.CaptureError(err, "", nil, "commit")
|
|
return nil, errx.InternalError()
|
|
}
|
|
|
|
return &seq, nil
|
|
}
|
|
|
|
func (r *sequenceRepository) Update(ctx context.Context, userID, campaignID, sequenceID string, data *models.UpdateSequence) (*models.Sequence, *errx.Error) {
|
|
setClauses := []string{}
|
|
args := []any{userID, campaignID, sequenceID}
|
|
argPos := 4
|
|
|
|
if data.Name != nil {
|
|
if len(*data.Name) > 50 {
|
|
return nil, errx.ErrSequenceName
|
|
}
|
|
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "name", argPos))
|
|
args = append(args, *data.Name)
|
|
argPos++
|
|
}
|
|
if data.Subject != nil {
|
|
if len(*data.Subject) > 100 {
|
|
return nil, errx.ErrSequenceSubject
|
|
}
|
|
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "subject", argPos))
|
|
args = append(args, *data.Subject)
|
|
argPos++
|
|
}
|
|
if data.BodyPlain != nil {
|
|
if len(*data.BodyPlain) > config.SequenceBodyLimit {
|
|
return nil, errx.ErrSequenceBody
|
|
}
|
|
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "body_plain", argPos))
|
|
args = append(args, *data.BodyPlain)
|
|
argPos++
|
|
}
|
|
if data.BodyHTML != nil {
|
|
if len(*data.BodyHTML) > config.SequenceBodyLimit {
|
|
return nil, errx.ErrSequenceBody
|
|
}
|
|
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "body_html", argPos))
|
|
args = append(args, *data.BodyHTML)
|
|
argPos++
|
|
}
|
|
if data.BodySync != nil {
|
|
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "body_sync", argPos))
|
|
args = append(args, *data.BodySync)
|
|
argPos++
|
|
}
|
|
if data.BodyCode != nil {
|
|
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "body_code", argPos))
|
|
args = append(args, *data.BodyCode)
|
|
argPos++
|
|
}
|
|
if data.WaitAfter != nil {
|
|
if *data.WaitAfter < 0 || *data.WaitAfter > config.SequenceWaitAfterMax {
|
|
return nil, errx.ErrSequenceWaitAfter
|
|
}
|
|
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "wait_after", argPos))
|
|
args = append(args, *data.WaitAfter)
|
|
argPos++
|
|
}
|
|
if data.Conditions != nil {
|
|
// Validate shape (operators/fields/values) before persisting. Cross-step
|
|
// validation (targets-in-campaign + no cycles) happens in the service
|
|
// layer where the full sequence set is available.
|
|
if verr := validateBranchConditions(data.Conditions); verr != nil {
|
|
return nil, verr
|
|
}
|
|
raw, merr := json.Marshal(data.Conditions)
|
|
if merr != nil {
|
|
db.CaptureError(merr, "", nil, "marshal_conditions")
|
|
return nil, errx.InternalError()
|
|
}
|
|
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "conditions", argPos))
|
|
args = append(args, raw)
|
|
argPos++
|
|
}
|
|
if data.Kind != nil {
|
|
if *data.Kind != "email" && *data.Kind != "action" && *data.Kind != "wait" {
|
|
return nil, errx.ErrSequenceKind
|
|
}
|
|
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "kind", argPos))
|
|
args = append(args, *data.Kind)
|
|
argPos++
|
|
}
|
|
if data.Action != nil {
|
|
if verr := validateActionConfig(data.Action); verr != nil {
|
|
return nil, verr
|
|
}
|
|
raw, merr := json.Marshal(data.Action)
|
|
if merr != nil {
|
|
db.CaptureError(merr, "", nil, "marshal_action")
|
|
return nil, errx.InternalError()
|
|
}
|
|
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "action", argPos))
|
|
args = append(args, raw)
|
|
argPos++
|
|
}
|
|
|
|
if argPos == 4 {
|
|
return nil, errx.ErrNotEnough
|
|
}
|
|
|
|
var seq models.Sequence
|
|
query := fmt.Sprintf(
|
|
`UPDATE sequences s
|
|
SET %s
|
|
FROM campaigns c
|
|
WHERE c.user_id = $1
|
|
AND c.id = $2
|
|
AND s.id = $3
|
|
RETURNING %s`,
|
|
strings.Join(setClauses, ", "),
|
|
SequenceSelectJoin,
|
|
)
|
|
|
|
row := r.DB.QueryRow(
|
|
ctx,
|
|
query,
|
|
args...,
|
|
)
|
|
err := GetSequence(row, &seq)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, errx.ErrNotFound
|
|
}
|
|
db.CaptureError(err, query, args, "queryrow")
|
|
return nil, errx.InternalError()
|
|
}
|
|
|
|
return &seq, nil
|
|
}
|
|
|
|
// UpdateLayout writes only canvas coordinates for a batch of steps under one
|
|
// campaign, in a single statement scoped to the campaign owner. It leaves every
|
|
// content column (and updated_at) untouched, so a position move never reads as a
|
|
// content change to teammates. Unknown ids are silently ignored.
|
|
func (r *sequenceRepository) UpdateLayout(ctx context.Context, userID, campaignID string, positions []models.SequencePosition) *errx.Error {
|
|
ids := make([]uuid.UUID, 0, len(positions))
|
|
xs := make([]float64, 0, len(positions))
|
|
ys := make([]float64, 0, len(positions))
|
|
for _, p := range positions {
|
|
id, err := uuid.Parse(p.ID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
ids = append(ids, id)
|
|
xs = append(xs, p.X)
|
|
ys = append(ys, p.Y)
|
|
}
|
|
if len(ids) == 0 {
|
|
return nil
|
|
}
|
|
|
|
query := `
|
|
UPDATE sequences AS s
|
|
SET x = v.x, y = v.y
|
|
FROM unnest($3::uuid[], $4::float8[], $5::float8[]) AS v(id, x, y)
|
|
WHERE s.id = v.id
|
|
AND s.campaign_id = $2
|
|
AND EXISTS (SELECT 1 FROM campaigns c WHERE c.id = $2 AND c.user_id = $1)`
|
|
args := []any{userID, campaignID, ids, xs, ys}
|
|
if _, err := r.DB.Exec(ctx, query, args...); err != nil {
|
|
db.CaptureError(err, query, args, "exec")
|
|
return errx.InternalError()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *sequenceRepository) Delete(ctx context.Context, userID, campaignID, sequenceID string) *errx.Error {
|
|
query := `
|
|
DELETE FROM sequences s
|
|
USING campaigns c
|
|
WHERE c.user_id = $1
|
|
AND c.id = $2
|
|
AND s.id = $3
|
|
`
|
|
|
|
params := []any{
|
|
userID,
|
|
campaignID,
|
|
sequenceID,
|
|
}
|
|
|
|
cmd, err := r.DB.Exec(
|
|
ctx,
|
|
query,
|
|
params...,
|
|
)
|
|
if err != nil {
|
|
db.CaptureError(err, query, params, "exec")
|
|
return errx.InternalError()
|
|
}
|
|
if cmd.RowsAffected() == 0 {
|
|
return errx.ErrNotFound
|
|
}
|
|
return nil
|
|
}
|