From 9bce2ad29eaacbe06580b9a70e4d62873bbbeade Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Sun, 24 May 2026 12:01:37 +0000 Subject: [PATCH] feat: full reply templates (search/reorder/duplicate/render + UI) --- internal/api/handler/template.go | 99 ++- internal/api/routes.go | 3 + internal/app/template/service.go | 105 ++- internal/app/template/service_test.go | 316 +++++++ internal/models/template.go | 26 + internal/repository/pg_template.go | 77 +- web/src/app/app/templates/page.tsx | 803 +++++++++++++++--- .../client/app/templates/createTemplate.ts | 5 +- .../client/app/templates/duplicateTemplate.ts | 10 + .../api/client/app/templates/listTemplates.ts | 13 +- .../client/app/templates/renderTemplate.ts | 14 + .../client/app/templates/reorderTemplates.ts | 13 + .../client/app/templates/updateTemplate.ts | 5 +- .../hooks/app/templates/useCreateTemplate.ts | 11 +- .../app/templates/useDuplicateTemplate.ts | 13 + .../app/templates/useReorderTemplates.ts | 13 + .../api/hooks/app/templates/useTemplates.ts | 8 +- .../hooks/app/templates/useUpdateTemplate.ts | 12 +- .../lib/api/models/app/templates/Template.ts | 41 +- 19 files changed, 1427 insertions(+), 160 deletions(-) create mode 100644 internal/app/template/service_test.go create mode 100644 web/src/lib/api/client/app/templates/duplicateTemplate.ts create mode 100644 web/src/lib/api/client/app/templates/renderTemplate.ts create mode 100644 web/src/lib/api/client/app/templates/reorderTemplates.ts create mode 100644 web/src/lib/api/hooks/app/templates/useDuplicateTemplate.ts create mode 100644 web/src/lib/api/hooks/app/templates/useReorderTemplates.ts diff --git a/internal/api/handler/template.go b/internal/api/handler/template.go index 90c6f86f..34f6e47e 100644 --- a/internal/api/handler/template.go +++ b/internal/api/handler/template.go @@ -10,7 +10,8 @@ import ( "github.com/warmbly/warmbly/internal/models" ) -// ListTemplates lists all reply templates for the organization +// ListTemplates lists all reply templates for the organization, optionally +// filtered by `?q=` against name and subject. // GET /templates func (h *Handler) ListTemplates(c *gin.Context) { orgID := middleware.GetOrganizationID(c) @@ -19,7 +20,10 @@ func (h *Handler) ListTemplates(c *gin.Context) { return } - templates, xerr := h.TemplateService.List(c.Request.Context(), *orgID) + var q models.ListReplyTemplatesQuery + _ = c.ShouldBindQuery(&q) + + templates, xerr := h.TemplateService.List(c.Request.Context(), *orgID, q.Search) if xerr != nil { errx.Handle(c, xerr) return @@ -134,3 +138,94 @@ func (h *Handler) DeleteTemplate(c *gin.Context) { c.Status(http.StatusNoContent) } + +// DuplicateTemplate clones a template, appending " (copy)" to the name +// and placing it at the end of the org's list. +// POST /templates/:id/duplicate +func (h *Handler) DuplicateTemplate(c *gin.Context) { + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.Handle(c, errx.New(errx.BadRequest, "no organization selected")) + return + } + + userID, err := middleware.GetUserUUID(c) + if err != nil { + errx.Handle(c, errx.ErrUser) + return + } + + templateID, err := uuid.Parse(c.Param("id")) + if err != nil { + errx.Handle(c, errx.ErrUuid) + return + } + + tmpl, xerr := h.TemplateService.Duplicate(c.Request.Context(), *orgID, userID, templateID) + if xerr != nil { + errx.Handle(c, xerr) + return + } + + c.JSON(http.StatusOK, tmpl) +} + +// ReorderTemplates reassigns positions across the org's templates so they +// line up with the order in the request body. +// PATCH /templates/reorder +func (h *Handler) ReorderTemplates(c *gin.Context) { + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.Handle(c, errx.New(errx.BadRequest, "no organization selected")) + return + } + + var data models.ReorderReplyTemplates + if err := c.ShouldBindJSON(&data); err != nil { + errx.Handle(c, errx.ErrInvalid) + return + } + + if xerr := h.TemplateService.Reorder(c.Request.Context(), *orgID, data.IDs); xerr != nil { + errx.Handle(c, xerr) + return + } + + templates, xerr := h.TemplateService.List(c.Request.Context(), *orgID, "") + if xerr != nil { + errx.Handle(c, xerr) + return + } + + c.JSON(http.StatusOK, models.ReplyTemplatesResult{Data: templates}) +} + +// RenderTemplate expands {{.Key}} placeholders in subject + body fields +// using a caller-supplied variable map. Used by Unibox to preview a reply +// before scheduling the send. +// POST /templates/:id/render +func (h *Handler) RenderTemplate(c *gin.Context) { + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.Handle(c, errx.New(errx.BadRequest, "no organization selected")) + return + } + + templateID, err := uuid.Parse(c.Param("id")) + if err != nil { + errx.Handle(c, errx.ErrUuid) + return + } + + var data models.RenderReplyTemplateRequest + // Body is optional: missing/empty body just renders all placeholders empty. + _ = c.ShouldBindJSON(&data) + + rendered, xerr := h.TemplateService.Render(c.Request.Context(), *orgID, templateID, data.Variables) + if xerr != nil { + errx.Handle(c, xerr) + return + } + + c.JSON(http.StatusOK, rendered) +} diff --git a/internal/api/routes.go b/internal/api/routes.go index 08de4eea..6f29ee7f 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -337,9 +337,12 @@ func Run( { templates.GET("", h.ListTemplates) templates.POST("", h.CreateTemplate) + templates.PATCH("/reorder", h.ReorderTemplates) templates.GET("/:id", h.GetTemplate) templates.PATCH("/:id", h.UpdateTemplate) templates.DELETE("/:id", h.DeleteTemplate) + templates.POST("/:id/duplicate", h.DuplicateTemplate) + templates.POST("/:id/render", h.RenderTemplate) } // CRM routes (require org) diff --git a/internal/app/template/service.go b/internal/app/template/service.go index f76ec04a..e1a5d9ae 100644 --- a/internal/app/template/service.go +++ b/internal/app/template/service.go @@ -2,6 +2,7 @@ package template import ( "context" + "strings" "github.com/google/uuid" "github.com/warmbly/warmbly/internal/errx" @@ -12,9 +13,12 @@ import ( type TemplateService interface { Create(ctx context.Context, orgID, userID uuid.UUID, data *models.CreateReplyTemplate) (*models.ReplyTemplate, *errx.Error) GetByID(ctx context.Context, orgID, templateID uuid.UUID) (*models.ReplyTemplate, *errx.Error) - List(ctx context.Context, orgID uuid.UUID) ([]models.ReplyTemplate, *errx.Error) + List(ctx context.Context, orgID uuid.UUID, search string) ([]models.ReplyTemplate, *errx.Error) Update(ctx context.Context, orgID, templateID uuid.UUID, data *models.UpdateReplyTemplate) (*models.ReplyTemplate, *errx.Error) Delete(ctx context.Context, orgID, templateID uuid.UUID) *errx.Error + Duplicate(ctx context.Context, orgID, userID, templateID uuid.UUID) (*models.ReplyTemplate, *errx.Error) + Reorder(ctx context.Context, orgID uuid.UUID, ids []uuid.UUID) *errx.Error + Render(ctx context.Context, orgID, templateID uuid.UUID, vars map[string]string) (*models.RenderedReplyTemplate, *errx.Error) } type templateService struct { @@ -26,6 +30,7 @@ func NewService(repo repository.TemplateRepository) TemplateService { } func (s *templateService) Create(ctx context.Context, orgID, userID uuid.UUID, data *models.CreateReplyTemplate) (*models.ReplyTemplate, *errx.Error) { + data.Name = strings.TrimSpace(data.Name) if data.Name == "" { return nil, errx.New(errx.BadRequest, "name is required") } @@ -53,8 +58,8 @@ func (s *templateService) GetByID(ctx context.Context, orgID, templateID uuid.UU return t, nil } -func (s *templateService) List(ctx context.Context, orgID uuid.UUID) ([]models.ReplyTemplate, *errx.Error) { - templates, err := s.repo.List(ctx, orgID) +func (s *templateService) List(ctx context.Context, orgID uuid.UUID, search string) ([]models.ReplyTemplate, *errx.Error) { + templates, err := s.repo.List(ctx, orgID, search) if err != nil { return nil, errx.InternalError() } @@ -67,8 +72,15 @@ func (s *templateService) List(ctx context.Context, orgID uuid.UUID) ([]models.R } func (s *templateService) Update(ctx context.Context, orgID, templateID uuid.UUID, data *models.UpdateReplyTemplate) (*models.ReplyTemplate, *errx.Error) { - if data.Name != nil && len(*data.Name) > 255 { - return nil, errx.New(errx.BadRequest, "name must be at most 255 characters") + if data.Name != nil { + trimmed := strings.TrimSpace(*data.Name) + if trimmed == "" { + return nil, errx.New(errx.BadRequest, "name cannot be empty") + } + if len(trimmed) > 255 { + return nil, errx.New(errx.BadRequest, "name must be at most 255 characters") + } + data.Name = &trimmed } t, err := s.repo.Update(ctx, orgID, templateID, data) @@ -89,3 +101,86 @@ func (s *templateService) Delete(ctx context.Context, orgID, templateID uuid.UUI return nil } + +func (s *templateService) Duplicate(ctx context.Context, orgID, userID, templateID uuid.UUID) (*models.ReplyTemplate, *errx.Error) { + t, err := s.repo.Duplicate(ctx, orgID, userID, templateID) + if err != nil { + return nil, errx.InternalError() + } + if t == nil { + return nil, errx.ErrNotFound + } + + return t, nil +} + +func (s *templateService) Reorder(ctx context.Context, orgID uuid.UUID, ids []uuid.UUID) *errx.Error { + if len(ids) == 0 { + return errx.New(errx.BadRequest, "ids cannot be empty") + } + + seen := make(map[uuid.UUID]struct{}, len(ids)) + for _, id := range ids { + if _, dup := seen[id]; dup { + return errx.New(errx.BadRequest, "duplicate id in reorder list") + } + seen[id] = struct{}{} + } + + if err := s.repo.Reorder(ctx, orgID, ids); err != nil { + return errx.InternalError() + } + + return nil +} + +// Render expands {{.Key}} placeholders in subject + body fields against the +// provided variable map. Missing keys are replaced with an empty string, +// matching the cold-email render semantics in internal/tasks/template.go. +func (s *templateService) Render(ctx context.Context, orgID, templateID uuid.UUID, vars map[string]string) (*models.RenderedReplyTemplate, *errx.Error) { + t, xerr := s.GetByID(ctx, orgID, templateID) + if xerr != nil { + return nil, xerr + } + + return &models.RenderedReplyTemplate{ + Subject: RenderString(t.Subject, vars), + BodyHTML: RenderString(t.BodyHTML, vars), + BodyPlain: RenderString(t.BodyPlain, vars), + }, nil +} + +// RenderString replaces {{.Key}} placeholders in s with values from vars. +// Unknown keys are dropped (rendered as empty string) so previews don't +// leak the placeholder syntax to recipients. +func RenderString(s string, vars map[string]string) string { + if s == "" { + return s + } + + out := s + for k, v := range vars { + out = strings.ReplaceAll(out, "{{."+k+"}}", v) + } + + // Drop any remaining {{.X}} placeholders so users never see raw syntax. + out = stripPlaceholders(out) + return out +} + +// stripPlaceholders removes any leftover {{.Anything}} tokens. It is +// intentionally permissive — anything between {{. and the next }} (no +// nested braces) is treated as a placeholder. +func stripPlaceholders(s string) string { + for { + start := strings.Index(s, "{{.") + if start < 0 { + return s + } + end := strings.Index(s[start:], "}}") + if end < 0 { + return s + } + s = s[:start] + s[start+end+2:] + } +} diff --git a/internal/app/template/service_test.go b/internal/app/template/service_test.go new file mode 100644 index 00000000..13d348b0 --- /dev/null +++ b/internal/app/template/service_test.go @@ -0,0 +1,316 @@ +package template + +import ( + "context" + "errors" + "testing" + + "github.com/google/uuid" + "github.com/warmbly/warmbly/internal/errx" + "github.com/warmbly/warmbly/internal/models" +) + +func TestRenderString_BasicSubstitution(t *testing.T) { + out := RenderString("Hi {{.FirstName}} at {{.Company}}", map[string]string{ + "FirstName": "Ada", + "Company": "Acme", + }) + if out != "Hi Ada at Acme" { + t.Fatalf("got %q", out) + } +} + +func TestRenderString_MissingPlaceholdersStripped(t *testing.T) { + // Unknown placeholders must NOT leak the {{.Key}} syntax into output. + out := RenderString("Hi {{.FirstName}} from {{.Unknown}}!", map[string]string{ + "FirstName": "Ada", + }) + if out != "Hi Ada from !" { + t.Fatalf("got %q", out) + } +} + +func TestRenderString_Empty(t *testing.T) { + if got := RenderString("", map[string]string{"a": "b"}); got != "" { + t.Fatalf("expected empty, got %q", got) + } +} + +func TestRenderString_NoPlaceholdersUnchanged(t *testing.T) { + src := "Just a regular sentence." + if got := RenderString(src, nil); got != src { + t.Fatalf("got %q", got) + } +} + +// fakeRepo is a minimal in-memory TemplateRepository for service tests. +// Only behaviors exercised by the tests are implemented faithfully. +type fakeRepo struct { + items map[uuid.UUID]*models.ReplyTemplate + listErr error + updateErr error + reorderHistory [][]uuid.UUID +} + +func newFakeRepo() *fakeRepo { + return &fakeRepo{items: map[uuid.UUID]*models.ReplyTemplate{}} +} + +func (f *fakeRepo) Create(_ context.Context, orgID, userID uuid.UUID, data *models.CreateReplyTemplate) (*models.ReplyTemplate, error) { + t := &models.ReplyTemplate{ + ID: uuid.New(), + OrganizationID: orgID, + UserID: userID, + Name: data.Name, + Subject: data.Subject, + BodyHTML: data.BodyHTML, + BodyPlain: data.BodyPlain, + Position: len(f.items) + 1, + } + f.items[t.ID] = t + return t, nil +} + +func (f *fakeRepo) GetByID(_ context.Context, orgID, templateID uuid.UUID) (*models.ReplyTemplate, error) { + t, ok := f.items[templateID] + if !ok || t.OrganizationID != orgID { + return nil, nil + } + return t, nil +} + +func (f *fakeRepo) List(_ context.Context, orgID uuid.UUID, _ string) ([]models.ReplyTemplate, error) { + if f.listErr != nil { + return nil, f.listErr + } + out := []models.ReplyTemplate{} + for _, t := range f.items { + if t.OrganizationID == orgID { + out = append(out, *t) + } + } + return out, nil +} + +func (f *fakeRepo) Update(_ context.Context, orgID, templateID uuid.UUID, data *models.UpdateReplyTemplate) (*models.ReplyTemplate, error) { + if f.updateErr != nil { + return nil, f.updateErr + } + t, ok := f.items[templateID] + if !ok || t.OrganizationID != orgID { + return nil, nil + } + if data.Name != nil { + t.Name = *data.Name + } + if data.Subject != nil { + t.Subject = *data.Subject + } + if data.BodyHTML != nil { + t.BodyHTML = *data.BodyHTML + } + if data.BodyPlain != nil { + t.BodyPlain = *data.BodyPlain + } + return t, nil +} + +func (f *fakeRepo) Delete(_ context.Context, orgID, templateID uuid.UUID) error { + if t, ok := f.items[templateID]; ok && t.OrganizationID == orgID { + delete(f.items, templateID) + } + return nil +} + +func (f *fakeRepo) Duplicate(ctx context.Context, orgID, userID, templateID uuid.UUID) (*models.ReplyTemplate, error) { + src, _ := f.GetByID(ctx, orgID, templateID) + if src == nil { + return nil, nil + } + return f.Create(ctx, orgID, userID, &models.CreateReplyTemplate{ + Name: src.Name + " (copy)", + Subject: src.Subject, + BodyHTML: src.BodyHTML, + BodyPlain: src.BodyPlain, + }) +} + +func (f *fakeRepo) Reorder(_ context.Context, orgID uuid.UUID, ids []uuid.UUID) error { + f.reorderHistory = append(f.reorderHistory, ids) + for i, id := range ids { + if t, ok := f.items[id]; ok && t.OrganizationID == orgID { + t.Position = i + 1 + } + } + return nil +} + +func TestService_Create_TrimsAndRejectsEmpty(t *testing.T) { + svc := NewService(newFakeRepo()) + ctx := context.Background() + orgID, userID := uuid.New(), uuid.New() + + if _, xerr := svc.Create(ctx, orgID, userID, &models.CreateReplyTemplate{Name: " "}); xerr == nil { + t.Fatal("expected error for whitespace-only name") + } + + t1, xerr := svc.Create(ctx, orgID, userID, &models.CreateReplyTemplate{Name: " Hello "}) + if xerr != nil { + t.Fatalf("create failed: %v", xerr) + } + if t1.Name != "Hello" { + t.Fatalf("expected name trimmed to 'Hello', got %q", t1.Name) + } +} + +func TestService_Update_RejectsEmptyAndOverlongName(t *testing.T) { + repo := newFakeRepo() + svc := NewService(repo) + ctx := context.Background() + orgID, userID := uuid.New(), uuid.New() + + t1, _ := svc.Create(ctx, orgID, userID, &models.CreateReplyTemplate{Name: "Initial"}) + empty := " " + if _, xerr := svc.Update(ctx, orgID, t1.ID, &models.UpdateReplyTemplate{Name: &empty}); xerr == nil { + t.Fatal("expected error for whitespace-only name") + } + long := make([]byte, 256) + for i := range long { + long[i] = 'a' + } + s := string(long) + if _, xerr := svc.Update(ctx, orgID, t1.ID, &models.UpdateReplyTemplate{Name: &s}); xerr == nil { + t.Fatal("expected error for >255 char name") + } +} + +func TestService_Update_NotFound(t *testing.T) { + svc := NewService(newFakeRepo()) + ctx := context.Background() + name := "x" + _, xerr := svc.Update(ctx, uuid.New(), uuid.New(), &models.UpdateReplyTemplate{Name: &name}) + if xerr == nil || xerr.Code != errx.NotFound { + t.Fatalf("expected NotFound, got %v", xerr) + } +} + +func TestService_List_InternalErrorBubbles(t *testing.T) { + repo := newFakeRepo() + repo.listErr = errors.New("boom") + svc := NewService(repo) + _, xerr := svc.List(context.Background(), uuid.New(), "") + if xerr == nil || xerr.Code != errx.Internal { + t.Fatalf("expected Internal, got %v", xerr) + } +} + +func TestService_List_NilCoercedToEmptySlice(t *testing.T) { + svc := NewService(newFakeRepo()) + out, xerr := svc.List(context.Background(), uuid.New(), "") + if xerr != nil { + t.Fatalf("unexpected err: %v", xerr) + } + if out == nil || len(out) != 0 { + t.Fatalf("expected empty slice, got %#v", out) + } +} + +func TestService_Duplicate(t *testing.T) { + repo := newFakeRepo() + svc := NewService(repo) + ctx := context.Background() + orgID, userID := uuid.New(), uuid.New() + + t1, _ := svc.Create(ctx, orgID, userID, &models.CreateReplyTemplate{Name: "Original", Subject: "Re:"}) + dup, xerr := svc.Duplicate(ctx, orgID, userID, t1.ID) + if xerr != nil { + t.Fatalf("duplicate failed: %v", xerr) + } + if dup.ID == t1.ID { + t.Fatal("duplicate should have a fresh ID") + } + if dup.Name != "Original (copy)" { + t.Fatalf("expected name 'Original (copy)', got %q", dup.Name) + } + if dup.Subject != "Re:" { + t.Fatalf("subject should be cloned, got %q", dup.Subject) + } +} + +func TestService_Reorder_RejectsEmpty(t *testing.T) { + svc := NewService(newFakeRepo()) + if xerr := svc.Reorder(context.Background(), uuid.New(), nil); xerr == nil { + t.Fatal("expected error for nil ids") + } +} + +func TestService_Reorder_RejectsDuplicates(t *testing.T) { + svc := NewService(newFakeRepo()) + id := uuid.New() + if xerr := svc.Reorder(context.Background(), uuid.New(), []uuid.UUID{id, id}); xerr == nil { + t.Fatal("expected error for duplicate ids") + } +} + +func TestService_Reorder_AppliesOrder(t *testing.T) { + repo := newFakeRepo() + svc := NewService(repo) + ctx := context.Background() + orgID, userID := uuid.New(), uuid.New() + + a, _ := svc.Create(ctx, orgID, userID, &models.CreateReplyTemplate{Name: "A"}) + b, _ := svc.Create(ctx, orgID, userID, &models.CreateReplyTemplate{Name: "B"}) + c, _ := svc.Create(ctx, orgID, userID, &models.CreateReplyTemplate{Name: "C"}) + + if xerr := svc.Reorder(ctx, orgID, []uuid.UUID{c.ID, a.ID, b.ID}); xerr != nil { + t.Fatalf("reorder failed: %v", xerr) + } + + got, _ := svc.GetByID(ctx, orgID, c.ID) + if got.Position != 1 { + t.Fatalf("expected c at pos 1, got %d", got.Position) + } + got, _ = svc.GetByID(ctx, orgID, b.ID) + if got.Position != 3 { + t.Fatalf("expected b at pos 3, got %d", got.Position) + } +} + +func TestService_Render_ExpandsTemplate(t *testing.T) { + repo := newFakeRepo() + svc := NewService(repo) + ctx := context.Background() + orgID, userID := uuid.New(), uuid.New() + + t1, _ := svc.Create(ctx, orgID, userID, &models.CreateReplyTemplate{ + Name: "Hello", + Subject: "Re: {{.Topic}}", + BodyHTML: "

Hi {{.FirstName}}

", + BodyPlain: "Hi {{.FirstName}}", + }) + + out, xerr := svc.Render(ctx, orgID, t1.ID, map[string]string{ + "Topic": "Pricing", + "FirstName": "Ada", + }) + if xerr != nil { + t.Fatalf("render failed: %v", xerr) + } + if out.Subject != "Re: Pricing" { + t.Fatalf("subject: %q", out.Subject) + } + if out.BodyHTML != "

Hi Ada

" { + t.Fatalf("html: %q", out.BodyHTML) + } + if out.BodyPlain != "Hi Ada" { + t.Fatalf("plain: %q", out.BodyPlain) + } +} + +func TestService_Render_NotFound(t *testing.T) { + svc := NewService(newFakeRepo()) + _, xerr := svc.Render(context.Background(), uuid.New(), uuid.New(), nil) + if xerr == nil || xerr.Code != errx.NotFound { + t.Fatalf("expected NotFound, got %v", xerr) + } +} diff --git a/internal/models/template.go b/internal/models/template.go index 57814267..3244728b 100644 --- a/internal/models/template.go +++ b/internal/models/template.go @@ -36,3 +36,29 @@ type UpdateReplyTemplate struct { type ReplyTemplatesResult struct { Data []ReplyTemplate `json:"data"` } + +// ListReplyTemplatesQuery accepts optional ?q= search filter that matches +// against name and subject (case-insensitive). +type ListReplyTemplatesQuery struct { + Search string `form:"q"` +} + +// ReorderReplyTemplates carries the new ordering for an organization's +// reply templates. Every ID in IDs is repositioned in the listed order +// (1-indexed). Templates not in the list are left untouched. +type ReorderReplyTemplates struct { + IDs []uuid.UUID `json:"ids" binding:"required"` +} + +// RenderReplyTemplateRequest expands template variables (e.g. {{.FirstName}}) +// against an arbitrary key/value map. Used by Unibox to preview a reply. +type RenderReplyTemplateRequest struct { + Variables map[string]string `json:"variables"` +} + +// RenderedReplyTemplate is the rendered output returned to the client. +type RenderedReplyTemplate struct { + Subject string `json:"subject"` + BodyHTML string `json:"body_html"` + BodyPlain string `json:"body_plain"` +} diff --git a/internal/repository/pg_template.go b/internal/repository/pg_template.go index 3d8f9e46..aaf83d82 100644 --- a/internal/repository/pg_template.go +++ b/internal/repository/pg_template.go @@ -15,9 +15,11 @@ import ( type TemplateRepository interface { Create(ctx context.Context, orgID, userID uuid.UUID, data *models.CreateReplyTemplate) (*models.ReplyTemplate, error) GetByID(ctx context.Context, orgID, templateID uuid.UUID) (*models.ReplyTemplate, error) - List(ctx context.Context, orgID uuid.UUID) ([]models.ReplyTemplate, error) + List(ctx context.Context, orgID uuid.UUID, search string) ([]models.ReplyTemplate, error) Update(ctx context.Context, orgID, templateID uuid.UUID, data *models.UpdateReplyTemplate) (*models.ReplyTemplate, error) Delete(ctx context.Context, orgID, templateID uuid.UUID) error + Duplicate(ctx context.Context, orgID, userID, templateID uuid.UUID) (*models.ReplyTemplate, error) + Reorder(ctx context.Context, orgID uuid.UUID, ids []uuid.UUID) error } type templateRepository struct { @@ -94,17 +96,25 @@ func (r *templateRepository) GetByID(ctx context.Context, orgID, templateID uuid return t, err } -// List retrieves reply templates for an organization with a hard limit to prevent unbounded queries -func (r *templateRepository) List(ctx context.Context, orgID uuid.UUID) ([]models.ReplyTemplate, error) { - query := ` +// List retrieves reply templates for an organization with a hard limit to prevent unbounded queries. +// If search is non-empty, name and subject are matched case-insensitively. +func (r *templateRepository) List(ctx context.Context, orgID uuid.UUID, search string) ([]models.ReplyTemplate, error) { + args := []interface{}{orgID} + where := "organization_id = $1" + if s := strings.TrimSpace(search); s != "" { + args = append(args, "%"+s+"%") + where += " AND (name ILIKE $2 OR subject ILIKE $2)" + } + + query := fmt.Sprintf(` SELECT id, organization_id, user_id, name, subject, body_html, body_plain, position, created_at, updated_at FROM reply_templates - WHERE organization_id = $1 - ORDER BY position ASC + WHERE %s + ORDER BY position ASC, created_at ASC LIMIT 500 - ` + `, where) - rows, err := r.db.Query(ctx, query, orgID) + rows, err := r.db.Query(ctx, query, args...) if err != nil { return nil, err } @@ -202,5 +212,56 @@ func (r *templateRepository) Delete(ctx context.Context, orgID, templateID uuid. return err } +// Duplicate copies an existing template under the calling user, appending +// " (copy)" to the name and placing it at the end of the org's list. +func (r *templateRepository) Duplicate(ctx context.Context, orgID, userID, templateID uuid.UUID) (*models.ReplyTemplate, error) { + src, err := r.GetByID(ctx, orgID, templateID) + if err != nil { + return nil, err + } + if src == nil { + return nil, nil + } + + name := src.Name + " (copy)" + if len(name) > 255 { + name = name[:255] + } + + return r.Create(ctx, orgID, userID, &models.CreateReplyTemplate{ + Name: name, + Subject: src.Subject, + BodyHTML: src.BodyHTML, + BodyPlain: src.BodyPlain, + }) +} + +// Reorder updates positions to match the given ID order. IDs not owned by +// the org are silently ignored. The operation runs in a single transaction +// so a partial failure leaves positions unchanged. +func (r *templateRepository) Reorder(ctx context.Context, orgID uuid.UUID, ids []uuid.UUID) error { + if len(ids) == 0 { + return nil + } + + tx, err := r.db.Begin(ctx) + if err != nil { + return err + } + defer func() { _ = tx.Rollback(ctx) }() + + for i, id := range ids { + _, err := tx.Exec(ctx, + `UPDATE reply_templates SET position = $1, updated_at = NOW() WHERE id = $2 AND organization_id = $3`, + i+1, id, orgID, + ) + if err != nil { + return err + } + } + + return tx.Commit(ctx) +} + // Ensure the type satisfies the interface at compile time var _ TemplateRepository = (*templateRepository)(nil) diff --git a/web/src/app/app/templates/page.tsx b/web/src/app/app/templates/page.tsx index c34947ed..dd5568a3 100644 --- a/web/src/app/app/templates/page.tsx +++ b/web/src/app/app/templates/page.tsx @@ -1,11 +1,26 @@ -// Templates — gallery preview. +// Templates — reusable subject + body for replies and cold opens. // -// The page is empty for now (no backend yet) but it shouldn't look -// like every other "coming soon" page. Instead we show a faux -// gallery of sample template cards so the user gets a sense of what -// templates look like before they build any. +// Layout mirrors the rest of the app (slate-900, hairline dividers, 12.5px +// text). A list of template rows on top, a modal editor that opens in +// place. Drag-style reordering is replaced with up/down chevrons so the +// page stays accessible without a dnd lib. -import { PlusIcon, MailOpenIcon, ReplyIcon, MousePointerClickIcon } from "lucide-react"; +import React from "react"; +import { + CopyIcon, + FileTextIcon, + Loader2Icon, + MoreHorizontalIcon, + PencilIcon, + PlusIcon, + SearchIcon, + TrashIcon, + XIcon, + ChevronUpIcon, + ChevronDownIcon, +} from "lucide-react"; +import toast from "react-hot-toast"; +import { AnimatePresence, motion } from "framer-motion"; import { Page, PageBody, @@ -15,60 +30,57 @@ import { StatStrip, TopbarAction, } from "@/components/layout/Page"; -import { comingSoon } from "@/lib/helper/comingSoon"; +import { Label, TextInput } from "@/components/ui/field"; +import useTemplates from "@/lib/api/hooks/app/templates/useTemplates"; +import useCreateTemplate from "@/lib/api/hooks/app/templates/useCreateTemplate"; +import useUpdateTemplate from "@/lib/api/hooks/app/templates/useUpdateTemplate"; +import useDeleteTemplate from "@/lib/api/hooks/app/templates/useDeleteTemplate"; +import useDuplicateTemplate from "@/lib/api/hooks/app/templates/useDuplicateTemplate"; +import useReorderTemplates from "@/lib/api/hooks/app/templates/useReorderTemplates"; +import useClickOutside from "@/hooks/useClickOutside"; +import { useConfirm } from "@/hooks/context/confirm"; +import type Template from "@/lib/api/models/app/templates/Template"; +import type { AppError } from "@/lib/api/client/normalizeError"; +import buildError from "@/lib/helper/buildError"; -const SAMPLE_TEMPLATES = [ - { - name: "Cold intro · Product", - subject: "Quick question, {{first_name}}", - preview: - "Hi {{first_name}}, I noticed {{company}} just shipped {{recent_announcement}}…", - tag: "Sales", - used: 12, - }, - { - name: "Follow-up · After 3 days", - subject: "Re: Quick question", - preview: - "Hey {{first_name}} — circling back on this. Worth a 15-min chat next Tue?", - tag: "Sales", - used: 8, - }, - { - name: "Re-engagement · 30 days", - subject: "Still on your radar?", - preview: - "It's been a while — wanted to share a couple of new things at {{our_company}}…", - tag: "Nurture", - used: 4, - }, - { - name: "Meeting confirm", - subject: "Looking forward to {{date}}", - preview: - "Just confirming our {{day}} call at {{time}}. Calendar invite attached.", - tag: "Ops", - used: 21, - }, - { - name: "Resource share", - subject: "Thought you'd find this useful", - preview: - "Pulled together a short breakdown of {{topic}} you mentioned…", - tag: "Nurture", - used: 3, - }, - { - name: "Renewal nudge", - subject: "Two weeks left on your plan", - preview: - "Quick heads up — your {{plan}} plan renews on {{date}}. Want to upgrade?", - tag: "Ops", - used: 6, - }, +const VARIABLE_HINTS = [ + "{{.FirstName}}", + "{{.LastName}}", + "{{.Email}}", + "{{.Company}}", + "{{.Phone}}", ]; export default function TemplatesPage() { + const [search, setSearch] = React.useState(""); + const [debouncedSearch, setDebouncedSearch] = React.useState(""); + React.useEffect(() => { + const t = setTimeout(() => setDebouncedSearch(search), 200); + return () => clearTimeout(t); + }, [search]); + + const query = useTemplates(debouncedSearch || undefined); + const list = query.data ?? []; + + const [editor, setEditor] = React.useState< + | { mode: "create" } + | { mode: "edit"; template: Template } + | null + >(null); + + const lastEdited = React.useMemo(() => { + if (list.length === 0) return "—"; + const max = list.reduce( + (acc, t) => Math.max(acc, new Date(t.updated_at).getTime()), + 0, + ); + if (!max) return "—"; + return new Date(max).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }); + }, [list]); + return ( } - onClick={() => comingSoon("Templates")} + onClick={() => setEditor({ mode: "create" })} > New template - - - - + + + + - - -
-

- Templates aren't shipped yet — here's a peek at the shape. - Each card holds a subject, a preview, an audience tag and - a usage counter, so you can find the right one fast from - any campaign. -

-
- -
- {SAMPLE_TEMPLATES.map((t, i) => ( -
+
+ + setSearch(e.target.value)} + placeholder="Search by name or subject…" + className="w-full h-7 pl-7 pr-7 rounded-md border border-slate-200 bg-white text-[12.5px] text-slate-900 placeholder:text-slate-400 outline-none transition-colors focus:border-sky-400 focus:ring-2 focus:ring-sky-100" + /> + {search && ( +
- ))} + + + )}
+ + {list.length} {list.length === 1 ? "template" : "templates"} + +
+ + + + {query.isPending ? ( + + ) : list.length === 0 ? ( + debouncedSearch ? ( + setSearch("")} + /> + ) : ( + setEditor({ mode: "create" })} /> + ) + ) : ( + setEditor({ mode: "edit", template: t })} + /> + )} + + setEditor(null)} + />
); } + +function SkeletonRows() { + return ( +
+ {[0, 1, 2].map((i) => ( +
+ ))} +
+ ); +} + +function EmptyState({ onCreate }: { onCreate: () => void }) { + return ( +
+
+ +
+

+ No templates yet +

+

+ Save the replies and openers you send over and over. + Drop variables like {VARIABLE_HINTS[0]} or {VARIABLE_HINTS[3]} so each + send picks up the right contact details. +

+ +
+ ); +} + +function EmptySearch({ search, onClear }: { search: string; onClear: () => void }) { + return ( +
+

+ No templates match "{search}". +

+ +
+ ); +} + +function TemplateList({ + templates, + onEdit, +}: { + templates: Template[]; + onEdit: (t: Template) => void; +}) { + const reorder = useReorderTemplates(); + + async function move(idx: number, dir: -1 | 1) { + const next = idx + dir; + if (next < 0 || next >= templates.length) return; + const ids = templates.map((t) => t.id); + const [moved] = ids.splice(idx, 1); + ids.splice(next, 0, moved); + try { + await reorder.mutateAsync(ids); + } catch (e) { + toast.error(buildError(e as AppError)); + } + } + + return ( +
+ {templates.map((t, idx) => ( + onEdit(t)} + onMoveUp={() => move(idx, -1)} + onMoveDown={() => move(idx, 1)} + reordering={reorder.isPending} + /> + ))} +
+ ); +} + +function TemplateRow({ + template, + index, + total, + onEdit, + onMoveUp, + onMoveDown, + reordering, +}: { + template: Template; + index: number; + total: number; + onEdit: () => void; + onMoveUp: () => void; + onMoveDown: () => void; + reordering: boolean; +}) { + const duplicate = useDuplicateTemplate(); + const del = useDeleteTemplate(); + const confirm = useConfirm(); + const [menuOpen, setMenuOpen] = React.useState(false); + const menuRef = React.useRef(null); + useClickOutside(menuRef, () => setMenuOpen(false)); + + const preview = previewText(template); + + async function doDuplicate() { + setMenuOpen(false); + try { + await toast.promise(duplicate.mutateAsync(template.id), { + loading: "Duplicating…", + success: "Template duplicated", + error: (e: AppError) => buildError(e), + }); + } catch { + /* surfaced */ + } + } + + function doDelete() { + setMenuOpen(false); + confirm?.show(`Delete template "${template.name}"? This can't be undone.`, async () => { + try { + await toast.promise(del.mutateAsync(template.id), { + loading: "Deleting…", + success: "Template deleted", + error: (e: AppError) => buildError(e), + }); + } catch { + /* surfaced */ + } + }); + } + + return ( +
+
+ + + {index + 1} + + +
+ + + +
+ + + {menuOpen && ( + + + +
+ + + )} + +
+
+ ); +} + +function previewText(t: Template) { + if (t.body_plain) return t.body_plain; + if (t.body_html) return stripHTML(t.body_html); + return ""; +} + +function stripHTML(s: string) { + return s + .replace(//gi, "") + .replace(//gi, "") + .replace(/<[^>]+>/g, " ") + .replace(/ /g, " ") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/\s+/g, " ") + .trim(); +} + +function formatRelative(date: Date | string) { + const d = typeof date === "string" ? new Date(date) : date; + const diff = Date.now() - d.getTime(); + const mins = Math.floor(diff / 60_000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 7) return `${days}d ago`; + return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); +} + +type EditorState = + | { mode: "create" } + | { mode: "edit"; template: Template } + | null; + +function TemplateEditor({ + state, + onClose, +}: { + state: EditorState; + onClose: () => void; +}) { + const create = useCreateTemplate(); + const update = useUpdateTemplate(); + + const [name, setName] = React.useState(""); + const [subject, setSubject] = React.useState(""); + const [bodyPlain, setBodyPlain] = React.useState(""); + const [bodyHTML, setBodyHTML] = React.useState(""); + const [showHTML, setShowHTML] = React.useState(false); + + React.useEffect(() => { + if (!state) return; + if (state.mode === "edit") { + setName(state.template.name); + setSubject(state.template.subject); + setBodyPlain(state.template.body_plain); + setBodyHTML(state.template.body_html); + setShowHTML(Boolean(state.template.body_html)); + } else { + setName(""); + setSubject(""); + setBodyPlain(""); + setBodyHTML(""); + setShowHTML(false); + } + }, [state]); + + async function submit() { + const trimmedName = name.trim(); + if (!trimmedName) { + toast.error("Name is required"); + return; + } + const payload = { + name: trimmedName, + subject, + body_plain: bodyPlain, + body_html: showHTML ? bodyHTML : "", + }; + try { + if (!state) return; + if (state.mode === "create") { + await toast.promise(create.mutateAsync(payload), { + loading: "Creating template…", + success: "Template created", + error: (e: AppError) => buildError(e), + }); + } else { + await toast.promise( + update.mutateAsync({ id: state.template.id, data: payload }), + { + loading: "Saving…", + success: "Template saved", + error: (e: AppError) => buildError(e), + }, + ); + } + onClose(); + } catch { + /* surfaced */ + } + } + + const pending = create.isPending || update.isPending; + const open = state !== null; + const isEdit = state?.mode === "edit"; + + function insertVariable(field: "subject" | "plain" | "html", token: string) { + const setter = + field === "subject" ? setSubject : field === "plain" ? setBodyPlain : setBodyHTML; + const current = + field === "subject" ? subject : field === "plain" ? bodyPlain : bodyHTML; + setter(current + token); + } + + return ( + + {open && ( + + e.stopPropagation()} + className="w-full max-w-[640px] rounded-lg bg-white border border-slate-200 shadow-[0_24px_48px_-12px_rgba(15,23,42,0.18)] overflow-hidden" + > +
+
+ +
+ + {isEdit ? "Edit" : "New"} + +
+ Template + +
+ +
+
+ + +
+ +
+
+ + insertVariable("subject", v)} /> +
+ +
+ +
+
+ + insertVariable("plain", v)} /> +
+