From e52f352ede769362db472a45427f7b24916920bd Mon Sep 17 00:00:00 2001 From: Sean Dean <254259913+distronode-com@users.noreply.github.com> Date: Fri, 4 Sep 2026 03:22:44 -0400 Subject: [PATCH 1/2] feat(event-types): duplicate an event type with all its child data (#17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /v1/event-types/{slug}/duplicate copies an event type and everything that hangs off it - intake questions, host assignments, event-type-specific availability rules, the reminder schedule, and the custom email subjects and notes - inside one transaction, plus a Duplicate action on each row of the event-types list. Three fields are deliberately not inherited: is_active is forced off (a copy exists to be edited, and publishing one on creation is the accident this endpoint has to avoid), archived_at is cleared (archiving belongs to the original's lifecycle), and the slug is regenerated as -copy, then -copy-2, -copy-3, … because it is UNIQUE across the instance. Everything else is copied verbatim, price_cents and currency included: zeroing a copied price is how a paid meeting quietly starts selling for nothing. Bookings are not copied. A copied booking would be a meeting nobody agreed to, with a live manage link, and it would distort both the per-invitee booking cap and the double-booking guard. Implementation notes: - The row copy is one INSERT … SELECT, so no inherited column's value passes through this process and none can be zeroed in transit. Because SQLite has no "copy every column" form, the column list is explicit - and a drift gate test reads pragma_table_info('event_types') and fails when a column is handled by neither the copy nor the documented not-inherited list, so a future migration cannot silently drop a field out of every copy. - Child rows are read into slices, each cursor closed, before anything is written. The pool is MaxOpenConns(1) (ARCHITECTURE §4), so a write issued while a cursor is open deadlocks on the connection that cursor holds. - Availability rules with event_type_id IS NULL are not copied: those are the host's global default and already apply to the copy. Copying one would promote a global rule to an event-specific one, and the original's later edits would stop reaching the copy. - Owner-scoped like PATCH and DELETE: an assigned host sees an event type read-only, so a copy they could not edit would be worse than a 404. Tests cover each child dataset, fresh child ids, the global-rule exclusion, slug uniqueness against another user's slug and against earlier copies, the archived source case, the non-owner 404, that no bookings are copied, and rollback when a child insert fails (forced with a trigger, asserting no half-built event type survives). --- CHANGELOG.md | 13 + frontend/src/routes/event-types/+page.svelte | 30 + internal/handler/event_type_duplicate.go | 351 +++++++++++ internal/handler/event_type_duplicate_test.go | 559 ++++++++++++++++++ internal/server/server.go | 1 + 5 files changed, 954 insertions(+) create mode 100644 internal/handler/event_type_duplicate.go create mode 100644 internal/handler/event_type_duplicate_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 1445c11..b4c8bb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,19 @@ exact tag (`ghcr.io/calnode/calnode:0.1.0`) if you need stability between upgrad ## [Unreleased] +### Added +- **Duplicate an event type.** `POST /v1/event-types/{slug}/duplicate`, and a Duplicate + action on each row of the event-types list. Closes + [#17](https://github.com/Calnode/calnode/issues/17). + + The copy carries everything that hangs off the original - intake questions, host + assignments, event-type-specific availability rules, the reminder schedule, and the + custom email subjects and notes - as a single transaction, so a half-built copy can + never be left behind. It is created inactive, under a generated `-copy` (then + `-copy-2`, `-copy-3`, …) slug, and keeps `price_cents`/`currency` verbatim: zeroing a + copied price is how a paid meeting quietly starts selling for nothing. Bookings are not + copied. + ## [0.8.0] - 2026-09-03 ### Added diff --git a/frontend/src/routes/event-types/+page.svelte b/frontend/src/routes/event-types/+page.svelte index f059a5e..ca33a5e 100644 --- a/frontend/src/routes/event-types/+page.svelte +++ b/frontend/src/routes/event-types/+page.svelte @@ -18,6 +18,9 @@ let creating = $state(false); let deleteOpen = $state(false); let deleteSlug = $state(''); + // Slug currently being duplicated, so the row's button can't be double-fired into two + // copies while the request is in flight. + let duplicating = $state(''); let filter = $state<'active' | 'archived'>('active'); const visible = $derived(items.filter((et) => (filter === 'archived' ? !!et.archived : !et.archived))); @@ -79,6 +82,22 @@ } } + // The copy is created inactive, so the toast names the new slug: nothing appears on a + // booking page until the operator edits it and switches it on. + async function duplicateEventType(et: EventType) { + if (duplicating) return; + duplicating = et.slug; + try { + const copy = await api.post(`/v1/event-types/${et.slug}/duplicate`); + toast.success(`Duplicated as "${copy.slug}" — inactive until you turn it on`); + await load(); + } catch (e: any) { + toast.error(e.message || 'Could not duplicate event type'); + } finally { + duplicating = ''; + } + } + function del(slug: string) { deleteSlug = slug; deleteOpen = true; @@ -219,6 +238,17 @@ {#if et.owned !== false} + + duplicateEventType(et)} + disabled={duplicating === et.slug} + > + + + + Duplicate + -copy", "-copy-2", … for a copy of +// srcSlug. +// +// event_types.slug is UNIQUE across the whole instance rather than per user, so the +// lookup is deliberately not owner-scoped: a slug taken by another user's event type is +// still taken. +// +// It runs inside the caller's transaction, so the check and the INSERT that consumes its +// answer are one atomic unit. Choosing the slug beforehand would leave a window in which +// a concurrent duplicate claims it and the INSERT dies on the constraint instead. +func uniqueCopySlug(ctx context.Context, tx *sql.Tx, srcSlug string) (string, error) { + for i := 1; i <= maxCopySlugAttempts; i++ { + candidate := srcSlug + copySlugSuffix + if i > 1 { + candidate = fmt.Sprintf("%s%s-%d", srcSlug, copySlugSuffix, i) + } + var exists int + err := tx.QueryRowContext(ctx, + `SELECT 1 FROM event_types WHERE slug = ?`, candidate).Scan(&exists) + if errors.Is(err, sql.ErrNoRows) { + return candidate, nil + } + if err != nil { + return "", err + } + } + return "", errNoFreeCopySlug +} + +// loadEventTypeChildren reads every child row of srcID into memory. +// +// Each cursor is drained and closed before the next statement runs, and nothing is +// written until all of them are closed. The pool is MaxOpenConns(1) (ARCHITECTURE §4), +// so a query issued while a cursor is open waits for the connection that cursor is +// holding — a deadlock that surfaces as a confusing "context deadline exceeded" rather +// than as a lock error. Same shape as loadHostSchedule and the calendar reconciler. +func loadEventTypeChildren(ctx context.Context, tx *sql.Tx, srcID string) (*eventTypeChildren, error) { + var c eventTypeChildren + + qRows, err := tx.QueryContext(ctx, ` + SELECT label, type, options, required, position + FROM event_type_questions WHERE event_type_id = ? ORDER BY position`, srcID) + if err != nil { + return nil, err + } + for qRows.Next() { + var q questionCopy + if err := qRows.Scan(&q.label, &q.qType, &q.options, &q.required, &q.position); err != nil { + qRows.Close() // #nosec G104 -- already returning the scan error; nothing more actionable + return nil, err + } + c.questions = append(c.questions, q) + } + qRows.Close() // #nosec G104 -- rows already fully consumed; nothing actionable on close error + if err := qRows.Err(); err != nil { + return nil, err + } + + hRows, err := tx.QueryContext(ctx, ` + SELECT user_id, role, priority + FROM event_type_hosts WHERE event_type_id = ? ORDER BY priority, user_id`, srcID) + if err != nil { + return nil, err + } + for hRows.Next() { + var hc hostCopy + if err := hRows.Scan(&hc.userID, &hc.role, &hc.priority); err != nil { + hRows.Close() // #nosec G104 -- already returning the scan error; nothing more actionable + return nil, err + } + c.hosts = append(c.hosts, hc) + } + hRows.Close() // #nosec G104 -- rows already fully consumed; nothing actionable on close error + if err := hRows.Err(); err != nil { + return nil, err + } + + // Event-specific rules only. A rule with event_type_id IS NULL is the host's global + // default and already applies to every event type they host, including this copy — + // copying it would silently promote a global rule into an event-specific one, and + // the original's later edits would then stop reaching the copy. + rRows, err := tx.QueryContext(ctx, ` + SELECT user_id, day_of_week, start_time, end_time + FROM availability_rules WHERE event_type_id = ? + ORDER BY user_id, day_of_week, start_time`, srcID) + if err != nil { + return nil, err + } + for rRows.Next() { + var ar availabilityRuleCopy + if err := rRows.Scan(&ar.userID, &ar.dayOfWeek, &ar.startTime, &ar.endTime); err != nil { + rRows.Close() // #nosec G104 -- already returning the scan error; nothing more actionable + return nil, err + } + c.rules = append(c.rules, ar) + } + rRows.Close() // #nosec G104 -- rows already fully consumed; nothing actionable on close error + if err := rRows.Err(); err != nil { + return nil, err + } + + remRows, err := tx.QueryContext(ctx, ` + SELECT hours_before FROM event_type_reminders + WHERE event_type_id = ? ORDER BY hours_before DESC`, srcID) + if err != nil { + return nil, err + } + for remRows.Next() { + var hb int + if err := remRows.Scan(&hb); err != nil { + remRows.Close() // #nosec G104 -- already returning the scan error; nothing more actionable + return nil, err + } + c.reminders = append(c.reminders, hb) + } + remRows.Close() // #nosec G104 -- rows already fully consumed; nothing actionable on close error + if err := remRows.Err(); err != nil { + return nil, err + } + + return &c, nil +} + +// insertEventTypeChildren writes the materialised child rows against newID, each with a +// fresh id. Runs in the same transaction as the parent row copy, so any failure here +// takes the whole duplicate with it. +func insertEventTypeChildren(ctx context.Context, tx *sql.Tx, newID string, c *eventTypeChildren) error { + for _, q := range c.questions { + if _, err := tx.ExecContext(ctx, ` + INSERT INTO event_type_questions (id, event_type_id, label, type, options, required, position) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + uid.New(), newID, q.label, q.qType, q.options, q.required, q.position); err != nil { + return fmt.Errorf("copy question: %w", err) + } + } + for _, hc := range c.hosts { + if _, err := tx.ExecContext(ctx, ` + INSERT INTO event_type_hosts (id, event_type_id, user_id, role, priority) + VALUES (?, ?, ?, ?, ?)`, + uid.New(), newID, hc.userID, hc.role, hc.priority); err != nil { + return fmt.Errorf("copy host: %w", err) + } + } + for _, ar := range c.rules { + if _, err := tx.ExecContext(ctx, ` + INSERT INTO availability_rules (id, user_id, event_type_id, day_of_week, start_time, end_time) + VALUES (?, ?, ?, ?, ?, ?)`, + uid.New(), ar.userID, newID, ar.dayOfWeek, ar.startTime, ar.endTime); err != nil { + return fmt.Errorf("copy availability rule: %w", err) + } + } + for _, hb := range c.reminders { + if _, err := tx.ExecContext(ctx, ` + INSERT INTO event_type_reminders (id, event_type_id, hours_before) + VALUES (?, ?, ?)`, uid.New(), newID, hb); err != nil { + return fmt.Errorf("copy reminder: %w", err) + } + } + return nil +} diff --git a/internal/handler/event_type_duplicate_test.go b/internal/handler/event_type_duplicate_test.go new file mode 100644 index 0000000..6bcdf33 --- /dev/null +++ b/internal/handler/event_type_duplicate_test.go @@ -0,0 +1,559 @@ +package handler_test + +import ( + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "sort" + "testing" + + "github.com/calnode/calnode/internal/handler" +) + +// duplicateResponse is the subset of the created copy the tests assert on. +type duplicateResponse struct { + ID string `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + Description *string `json:"description"` + DurationMinutes int `json:"duration_minutes"` + SlotIntervalMinutes int `json:"slot_interval_minutes"` + LocationType string `json:"location_type"` + LocationValue *string `json:"location_value"` + RoutingMode string `json:"routing_mode"` + RRStrategy string `json:"rr_strategy"` + BufferBeforeMinutes int `json:"buffer_before_minutes"` + BufferAfterMinutes int `json:"buffer_after_minutes"` + MinNoticeMinutes int `json:"min_notice_minutes"` + MaxFutureDays int `json:"max_future_days"` + MaxActiveBookings int `json:"max_active_bookings"` + IsActive bool `json:"is_active"` + IsPublic bool `json:"is_public"` + ShowTakenSlots bool `json:"show_taken_slots"` + Archived bool `json:"archived"` + MsgConfirmation *string `json:"msg_confirmation"` + SubjConfirmation *string `json:"subj_confirmation"` + MsgGreeting *string `json:"msg_greeting"` + PriceCents int `json:"price_cents"` + Currency string `json:"currency"` + Reminders []int `json:"reminders"` +} + +// duplicate POSTs /v1/event-types/{slug}/duplicate and returns the recorder. +func duplicate(t *testing.T, h *handler.Handler, apiKey, slug string) *httptest.ResponseRecorder { + t.Helper() + req := authReq(http.MethodPost, "/v1/event-types/"+slug+"/duplicate", "", apiKey) + req.SetPathValue("slug", slug) + rec := httptest.NewRecorder() + h.RequireAuth(h.DuplicateEventType)(rec, req) + return rec +} + +// seedRichEventType inserts an event type with a non-default value in every column that +// a copy has to carry, plus one row in each child table. Direct SQL rather than the API +// so columns the editor doesn't expose (seat_limit, team_id) are covered too. +func seedRichEventType(t *testing.T, database *sql.DB, ownerID, memberID string) { + t.Helper() + mustExec(t, database, ` + INSERT INTO event_types ( + id, user_id, slug, name, description, + duration_minutes, slot_interval_minutes, location_type, location_value, + routing_mode, rr_strategy, buffer_before_minutes, buffer_after_minutes, + min_notice_minutes, max_future_days, max_active_bookings, seat_limit, + is_active, is_public, show_taken_slots, + msg_confirmation, msg_cancellation, msg_reschedule, msg_reminder, msg_greeting, + subj_confirmation, subj_cancellation, subj_reschedule, subj_reminder, + price_cents, currency) + VALUES ( + 'src', ?, 'intro-call', 'Intro Call', 'A chat about the role', + 45, 15, 'phone', '+15550100', + 'round_robin', 'priority', 10, 20, + 240, 45, 3, 4, + 1, 0, 1, + 'See you soon', 'Sorry to miss you', 'New time below', 'Coming up', + 'Hi! When suits you?', + 'Confirmed: intro', 'Cancelled: intro', 'Moved: intro', 'Reminder: intro', + 5000, 'eur')`, ownerID) + + // Intake form: a required free-text question and an optional select with options. + mustExec(t, database, ` + INSERT INTO event_type_questions (id, event_type_id, label, type, options, required, position) + VALUES ('q1', 'src', 'What would you like to cover?', 'text', NULL, 1, 0)`) + mustExec(t, database, ` + INSERT INTO event_type_questions (id, event_type_id, label, type, options, required, position) + VALUES ('q2', 'src', 'How did you hear about us?', 'select', '["Search","A friend"]', 0, 1)`) + + // Host list: the owner always attends, the member is in the rotation. + mustExec(t, database, ` + INSERT INTO event_type_hosts (id, event_type_id, user_id, role, priority) + VALUES ('h1', 'src', ?, 'required', 0)`, ownerID) + mustExec(t, database, ` + INSERT INTO event_type_hosts (id, event_type_id, user_id, role, priority) + VALUES ('h2', 'src', ?, 'rotation', 1)`, memberID) + + // Two availability rules: one specific to this event type, one global default. Only + // the first belongs in the copy. + mustExec(t, database, ` + INSERT INTO availability_rules (id, user_id, event_type_id, day_of_week, start_time, end_time) + VALUES ('ar1', ?, 'src', 1, '09:00', '12:00')`, ownerID) + mustExec(t, database, ` + INSERT INTO availability_rules (id, user_id, event_type_id, day_of_week, start_time, end_time) + VALUES ('ar2', ?, NULL, 2, '13:00', '17:00')`, ownerID) + + mustExec(t, database, `INSERT INTO event_type_reminders (id, event_type_id, hours_before) VALUES ('r1', 'src', 24)`) + mustExec(t, database, `INSERT INTO event_type_reminders (id, event_type_id, hours_before) VALUES ('r2', 'src', 2)`) + + // A booking on the source, to prove history is not copied. + mustExec(t, database, ` + INSERT INTO bookings (id, event_type_id, host_id, start_at, end_at, status) + VALUES ('b1', 'src', ?, '2099-01-01T10:00:00Z', '2099-01-01T10:45:00Z', 'confirmed')`, ownerID) +} + +func mustExec(t *testing.T, database *sql.DB, query string, args ...any) { + t.Helper() + if _, err := database.Exec(query, args...); err != nil { + t.Fatalf("seed: %v\n query: %s", err, query) + } +} + +// seedMember inserts a second, active workspace member. +func seedMember(t *testing.T, database *sql.DB, id, email string) string { + t.Helper() + mustExec(t, database, + `INSERT INTO users (id, email, name, iana_timezone, is_admin) VALUES (?, ?, 'Member', 'UTC', 0)`, id, email) + return id +} + +func TestDuplicateEventType_copiesTheRowAndEveryChildDataset(t *testing.T) { + h, database, ownerKey, ownerID := setupWorkspaceWithDB(t) + memberID := seedMember(t, database, "u2", "member@example.com") + seedRichEventType(t, database, ownerID, memberID) + + rec := duplicate(t, h, ownerKey, "intro-call") + if rec.Code != http.StatusCreated { + t.Fatalf("duplicate: got %d; want 201 — %s", rec.Code, rec.Body.String()) + } + var got duplicateResponse + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode response: %v", err) + } + + // ── the row itself ──────────────────────────────────────────────────────── + if got.ID == "src" { + t.Error("the copy reused the source id") + } + if got.Slug != "intro-call-copy" { + t.Errorf("slug: got %q; want %q", got.Slug, "intro-call-copy") + } + if got.IsActive { + t.Error("a copy must be created inactive so it cannot be published by accident") + } + if got.Archived { + t.Error("a copy must not be archived") + } + // price_cents/currency faithfully, NOT zeroed: an operator who publishes a copy + // believing it kept its price must not start selling the meeting for nothing. + if got.PriceCents != 5000 || got.Currency != "eur" { + t.Errorf("price: got %d %q; want 5000 \"eur\"", got.PriceCents, got.Currency) + } + if got.Name != "Intro Call" { + t.Errorf("name: got %q; want %q", got.Name, "Intro Call") + } + if got.Description == nil || *got.Description != "A chat about the role" { + t.Errorf("description: got %v", got.Description) + } + if got.DurationMinutes != 45 || got.SlotIntervalMinutes != 15 { + t.Errorf("duration/interval: got %d/%d; want 45/15", got.DurationMinutes, got.SlotIntervalMinutes) + } + if got.LocationType != "phone" || got.LocationValue == nil || *got.LocationValue != "+15550100" { + t.Errorf("location: got %q/%v", got.LocationType, got.LocationValue) + } + if got.RoutingMode != "round_robin" || got.RRStrategy != "priority" { + t.Errorf("routing: got %q/%q; want round_robin/priority", got.RoutingMode, got.RRStrategy) + } + if got.BufferBeforeMinutes != 10 || got.BufferAfterMinutes != 20 { + t.Errorf("buffers: got %d/%d; want 10/20", got.BufferBeforeMinutes, got.BufferAfterMinutes) + } + if got.MinNoticeMinutes != 240 || got.MaxFutureDays != 45 || got.MaxActiveBookings != 3 { + t.Errorf("limits: got %d/%d/%d; want 240/45/3", + got.MinNoticeMinutes, got.MaxFutureDays, got.MaxActiveBookings) + } + if got.IsPublic { + t.Error("is_public: got true; want the source's false") + } + if !got.ShowTakenSlots { + t.Error("show_taken_slots: got false; want the source's true") + } + if got.MsgConfirmation == nil || *got.MsgConfirmation != "See you soon" { + t.Errorf("msg_confirmation: got %v", got.MsgConfirmation) + } + if got.SubjConfirmation == nil || *got.SubjConfirmation != "Confirmed: intro" { + t.Errorf("subj_confirmation: got %v", got.SubjConfirmation) + } + if got.MsgGreeting == nil || *got.MsgGreeting != "Hi! When suits you?" { + t.Errorf("msg_greeting: got %v", got.MsgGreeting) + } + + // seat_limit isn't in the API shape; check it on the row. + var seatLimit int + var createdAt string + if err := database.QueryRow( + `SELECT seat_limit, created_at FROM event_types WHERE id = ?`, got.ID). + Scan(&seatLimit, &createdAt); err != nil { + t.Fatalf("read copy row: %v", err) + } + if seatLimit != 4 { + t.Errorf("seat_limit: got %d; want 4", seatLimit) + } + if createdAt == "" { + t.Error("the copy has no created_at") + } + + // ── intake questions ────────────────────────────────────────────────────── + type qRow struct { + id, label, qType string + options sql.NullString + required, pos int + } + var questions []qRow + rows, err := database.Query(` + SELECT id, label, type, options, required, position + FROM event_type_questions WHERE event_type_id = ? ORDER BY position`, got.ID) + if err != nil { + t.Fatalf("read copied questions: %v", err) + } + for rows.Next() { + var q qRow + if err := rows.Scan(&q.id, &q.label, &q.qType, &q.options, &q.required, &q.pos); err != nil { + t.Fatalf("scan question: %v", err) + } + questions = append(questions, q) + } + rows.Close() + if len(questions) != 2 { + t.Fatalf("copied questions: got %d; want 2", len(questions)) + } + if questions[0].label != "What would you like to cover?" || questions[0].qType != "text" || + questions[0].required != 1 || questions[0].pos != 0 { + t.Errorf("question 1 not copied faithfully: %+v", questions[0]) + } + if questions[0].options.Valid { + t.Errorf("question 1 options: got %q; want NULL", questions[0].options.String) + } + if questions[1].label != "How did you hear about us?" || questions[1].qType != "select" || + questions[1].options.String != `["Search","A friend"]` || questions[1].required != 0 || questions[1].pos != 1 { + t.Errorf("question 2 not copied faithfully: %+v", questions[1]) + } + for _, q := range questions { + if q.id == "q1" || q.id == "q2" { + t.Errorf("copied question reused the source id %q — the rows would be shared, not copied", q.id) + } + } + + // ── host assignments ────────────────────────────────────────────────────── + type hRow struct { + id, userID, role string + priority int + } + var hosts []hRow + rows, err = database.Query(` + SELECT id, user_id, role, priority FROM event_type_hosts + WHERE event_type_id = ? ORDER BY priority`, got.ID) + if err != nil { + t.Fatalf("read copied hosts: %v", err) + } + for rows.Next() { + var hr hRow + if err := rows.Scan(&hr.id, &hr.userID, &hr.role, &hr.priority); err != nil { + t.Fatalf("scan host: %v", err) + } + hosts = append(hosts, hr) + } + rows.Close() + if len(hosts) != 2 { + t.Fatalf("copied hosts: got %d; want 2", len(hosts)) + } + if hosts[0].userID != ownerID || hosts[0].role != "required" || hosts[0].priority != 0 { + t.Errorf("host 1 not copied faithfully: %+v", hosts[0]) + } + if hosts[1].userID != memberID || hosts[1].role != "rotation" || hosts[1].priority != 1 { + t.Errorf("host 2 not copied faithfully: %+v", hosts[1]) + } + for _, hr := range hosts { + if hr.id == "h1" || hr.id == "h2" { + t.Errorf("copied host reused the source id %q", hr.id) + } + } + + // ── availability rules ──────────────────────────────────────────────────── + var ruleCount int + var ruleUser, ruleStart, ruleEnd string + var ruleDOW int + if err := database.QueryRow( + `SELECT COUNT(*) FROM availability_rules WHERE event_type_id = ?`, got.ID).Scan(&ruleCount); err != nil { + t.Fatalf("count copied rules: %v", err) + } + if ruleCount != 1 { + t.Fatalf("copied availability rules: got %d; want 1 (the event-specific rule only)", ruleCount) + } + if err := database.QueryRow(` + SELECT user_id, day_of_week, start_time, end_time FROM availability_rules + WHERE event_type_id = ?`, got.ID).Scan(&ruleUser, &ruleDOW, &ruleStart, &ruleEnd); err != nil { + t.Fatalf("read copied rule: %v", err) + } + if ruleUser != ownerID || ruleDOW != 1 || ruleStart != "09:00" || ruleEnd != "12:00" { + t.Errorf("availability rule not copied faithfully: %s day=%d %s-%s", ruleUser, ruleDOW, ruleStart, ruleEnd) + } + // The host's global rule must still be exactly one global rule: it already applies + // to the copy, so promoting it to an event-specific row would be a behaviour change. + var globalRules int + if err := database.QueryRow( + `SELECT COUNT(*) FROM availability_rules WHERE event_type_id IS NULL`).Scan(&globalRules); err != nil { + t.Fatalf("count global rules: %v", err) + } + if globalRules != 1 { + t.Errorf("global availability rules: got %d; want 1 — a global rule must not be copied", globalRules) + } + + // ── reminders ───────────────────────────────────────────────────────────── + sort.Ints(got.Reminders) + if len(got.Reminders) != 2 || got.Reminders[0] != 2 || got.Reminders[1] != 24 { + t.Errorf("reminders in the response: got %v; want [2 24]", got.Reminders) + } + var reminderCount int + if err := database.QueryRow( + `SELECT COUNT(*) FROM event_type_reminders WHERE event_type_id = ?`, got.ID).Scan(&reminderCount); err != nil { + t.Fatalf("count copied reminders: %v", err) + } + if reminderCount != 2 { + t.Errorf("copied reminders: got %d; want 2", reminderCount) + } + + // ── bookings are NOT copied ─────────────────────────────────────────────── + var bookings int + if err := database.QueryRow( + `SELECT COUNT(*) FROM bookings WHERE event_type_id = ?`, got.ID).Scan(&bookings); err != nil { + t.Fatalf("count copied bookings: %v", err) + } + if bookings != 0 { + t.Errorf("bookings copied onto the duplicate: got %d; want 0", bookings) + } +} + +// TestDuplicateEventType_slugIsUniqueAcrossTheInstance covers both halves of the slug +// rule: a second copy doesn't collide with the first, and a slug already taken by +// ANOTHER user's event type is still taken (event_types.slug is globally unique). +func TestDuplicateEventType_slugIsUniqueAcrossTheInstance(t *testing.T) { + h, database, ownerKey, ownerID := setupWorkspaceWithDB(t) + memberID := seedMember(t, database, "u2", "member@example.com") + mustExec(t, database, ` + INSERT INTO event_types (id, user_id, slug, name, duration_minutes) + VALUES ('src', ?, 'intro-call', 'Intro Call', 30)`, ownerID) + // Another user already owns "intro-call-copy". + mustExec(t, database, ` + INSERT INTO event_types (id, user_id, slug, name, duration_minutes) + VALUES ('other', ?, 'intro-call-copy', 'Squatter', 30)`, memberID) + + rec := duplicate(t, h, ownerKey, "intro-call") + if rec.Code != http.StatusCreated { + t.Fatalf("first duplicate: got %d; want 201 — %s", rec.Code, rec.Body.String()) + } + var first duplicateResponse + json.Unmarshal(rec.Body.Bytes(), &first) //nolint:errcheck + if first.Slug != "intro-call-copy-2" { + t.Errorf("slug: got %q; want %q (\"-copy\" is taken by another user)", first.Slug, "intro-call-copy-2") + } + + rec = duplicate(t, h, ownerKey, "intro-call") + if rec.Code != http.StatusCreated { + t.Fatalf("second duplicate: got %d; want 201 — %s", rec.Code, rec.Body.String()) + } + var second duplicateResponse + json.Unmarshal(rec.Body.Bytes(), &second) //nolint:errcheck + if second.Slug != "intro-call-copy-3" { + t.Errorf("slug: got %q; want %q", second.Slug, "intro-call-copy-3") + } +} + +// TestDuplicateEventType_archivedSourceProducesALiveDraft — archiving describes the +// original's lifecycle, so the copy starts un-archived (and, as always, inactive). +func TestDuplicateEventType_archivedSourceProducesALiveDraft(t *testing.T) { + h, database, ownerKey, ownerID := setupWorkspaceWithDB(t) + mustExec(t, database, ` + INSERT INTO event_types (id, user_id, slug, name, duration_minutes, is_active, archived_at) + VALUES ('src', ?, 'intro-call', 'Intro Call', 30, 0, '2026-01-01T00:00:00Z')`, ownerID) + + rec := duplicate(t, h, ownerKey, "intro-call") + if rec.Code != http.StatusCreated { + t.Fatalf("duplicate: got %d; want 201 — %s", rec.Code, rec.Body.String()) + } + var got duplicateResponse + json.Unmarshal(rec.Body.Bytes(), &got) //nolint:errcheck + + var archivedAt sql.NullString + var isActive int + if err := database.QueryRow( + `SELECT archived_at, is_active FROM event_types WHERE id = ?`, got.ID).Scan(&archivedAt, &isActive); err != nil { + t.Fatalf("read copy: %v", err) + } + if archivedAt.Valid { + t.Errorf("archived_at: got %q; want NULL", archivedAt.String) + } + if isActive != 0 { + t.Error("the copy must still be inactive") + } +} + +func TestDuplicateEventType_onlyTheOwnerMayDuplicate(t *testing.T) { + h, database, _, ownerID := setupWorkspaceWithDB(t) + hostKey := "assigned-host-key" + memberID := seedMember(t, database, "u2", "member@example.com") + mustExec(t, database, + `INSERT INTO api_keys (id, user_id, name, key_hash, created_at) VALUES ('k2', ?, 't', ?, '2024-01-01')`, + memberID, sha256HexForTest(hostKey)) + mustExec(t, database, ` + INSERT INTO event_types (id, user_id, slug, name, duration_minutes) + VALUES ('src', ?, 'intro-call', 'Intro Call', 30)`, ownerID) + // u2 is an assigned host on the owner's event type — visible, but read-only. + mustExec(t, database, ` + INSERT INTO event_type_hosts (id, event_type_id, user_id, role, priority) + VALUES ('h2', 'src', ?, 'rotation', 1)`, memberID) + + rec := duplicate(t, h, hostKey, "intro-call") + if rec.Code != http.StatusNotFound { + t.Errorf("assigned host duplicating: got %d; want 404 — %s", rec.Code, rec.Body.String()) + } + var copies int + if err := database.QueryRow( + `SELECT COUNT(*) FROM event_types WHERE slug LIKE 'intro-call-copy%'`).Scan(&copies); err != nil { + t.Fatalf("count copies: %v", err) + } + if copies != 0 { + t.Errorf("copies created by a non-owner: got %d; want 0", copies) + } +} + +func TestDuplicateEventType_unknownSlugIs404(t *testing.T) { + h, _, ownerKey, _ := setupWorkspaceWithDB(t) + rec := duplicate(t, h, ownerKey, "does-not-exist") + if rec.Code != http.StatusNotFound { + t.Errorf("got %d; want 404 — %s", rec.Code, rec.Body.String()) + } +} + +// TestDuplicateEventType_rollsBackWhenAChildCopyFails proves the copy is one +// transaction: with the child insert forced to fail, no half-built event type is left +// behind. A partial copy would be worse than no copy — it looks like a real event type +// and is missing exactly the parts nobody thinks to check. +func TestDuplicateEventType_rollsBackWhenAChildCopyFails(t *testing.T) { + h, database, ownerKey, ownerID := setupWorkspaceWithDB(t) + memberID := seedMember(t, database, "u2", "member@example.com") + seedRichEventType(t, database, ownerID, memberID) + + // Fail the question copy at the database, so the handler takes the same path a real + // constraint violation would. + mustExec(t, database, ` + CREATE TRIGGER fail_question_copy BEFORE INSERT ON event_type_questions + BEGIN SELECT RAISE(ABORT, 'forced failure'); END`) + + rec := duplicate(t, h, ownerKey, "intro-call") + if rec.Code != http.StatusInternalServerError { + t.Fatalf("duplicate with a failing child insert: got %d; want 500 — %s", rec.Code, rec.Body.String()) + } + for _, q := range []struct{ what, query string }{ + {"event type", `SELECT COUNT(*) FROM event_types WHERE slug LIKE 'intro-call-copy%'`}, + {"hosts", `SELECT COUNT(*) FROM event_type_hosts WHERE event_type_id != 'src'`}, + {"availability rules", `SELECT COUNT(*) FROM availability_rules WHERE event_type_id IS NOT NULL AND event_type_id != 'src'`}, + {"reminders", `SELECT COUNT(*) FROM event_type_reminders WHERE event_type_id != 'src'`}, + } { + var n int + if err := database.QueryRow(q.query).Scan(&n); err != nil { + t.Fatalf("count %s: %v", q.what, err) + } + if n != 0 { + t.Errorf("%s left behind after the failed copy: got %d; want 0", q.what, n) + } + } +} + +// TestDuplicateEventType_handlesEveryEventTypeColumn is a drift gate, not a behaviour +// test. DuplicateEventType names its columns explicitly (SQLite has no "copy every +// column" form), so a migration that adds one would silently leave it out of every +// future copy — a data-loss bug with no failing test anywhere near it. +// +// Adding a column to event_types therefore means adding it to ONE of the two lists +// below: to the INSERT … SELECT in event_type_duplicate.go (the normal answer), or here +// to notInherited with a reason. +func TestDuplicateEventType_handlesEveryEventTypeColumn(t *testing.T) { + _, database, _, _ := setupWorkspaceWithDB(t) + + // Copied by the INSERT … SELECT in DuplicateEventType. + copied := map[string]bool{ + "user_id": true, "team_id": true, "name": true, "description": true, + "duration_minutes": true, "slot_interval_minutes": true, + "location_type": true, "location_value": true, + "routing_mode": true, "rr_strategy": true, + "buffer_before_minutes": true, "buffer_after_minutes": true, + "min_notice_minutes": true, "max_future_days": true, + "max_active_bookings": true, "seat_limit": true, + "is_public": true, "show_taken_slots": true, + "msg_confirmation": true, "msg_cancellation": true, "msg_reschedule": true, + "msg_reminder": true, "msg_greeting": true, + "subj_confirmation": true, "subj_cancellation": true, "subj_reschedule": true, + "subj_reminder": true, + "price_cents": true, "currency": true, + } + // Deliberately not inherited, with the reason. + notInherited := map[string]string{ + "id": "a copy is a new row", + "slug": "UNIQUE; regenerated as -copy[-N]", + "is_active": "forced to 0 so a copy is never published by accident", + "archived_at": "cleared; archiving belongs to the original's lifecycle", + "created_at": "the copy was created now, not when the original was", + } + + rows, err := database.Query(`SELECT name FROM pragma_table_info('event_types')`) + if err != nil { + t.Fatalf("pragma_table_info: %v", err) + } + defer rows.Close() + var columns []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + t.Fatalf("scan column name: %v", err) + } + columns = append(columns, name) + } + if err := rows.Err(); err != nil { + t.Fatalf("pragma rows: %v", err) + } + if len(columns) == 0 { + t.Fatal("pragma_table_info returned no columns for event_types") + } + + seen := map[string]bool{} + for _, col := range columns { + seen[col] = true + if copied[col] { + continue + } + if _, ok := notInherited[col]; ok { + continue + } + t.Errorf("event_types.%s is handled by neither list: add it to the INSERT … SELECT in "+ + "DuplicateEventType (and to `copied` here), or to `notInherited` with a reason", col) + } + for col := range copied { + if !seen[col] { + t.Errorf("event_types.%s no longer exists — drop it from `copied` and from the "+ + "INSERT … SELECT in DuplicateEventType", col) + } + } + for col := range notInherited { + if !seen[col] { + t.Errorf("event_types.%s no longer exists — drop it from `notInherited`", col) + } + } +} diff --git a/internal/server/server.go b/internal/server/server.go index d770024..39070a9 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -384,6 +384,7 @@ func New(ctx context.Context, cfg *config.Config, db *sql.DB, logger *slog.Logge mux.HandleFunc("GET /v1/event-types/{slug}", h.RequireAuth(h.GetEventType)) mux.HandleFunc("PATCH /v1/event-types/{slug}", h.RequireAuth(h.PatchEventType)) mux.HandleFunc("DELETE /v1/event-types/{slug}", h.RequireAuth(h.DeleteEventType)) + mux.HandleFunc("POST /v1/event-types/{slug}/duplicate", h.RequireAuth(h.DuplicateEventType)) mux.HandleFunc("GET /v1/event-types/{slug}/hosts", h.RequireAuth(h.ListEventTypeHosts)) mux.HandleFunc("PUT /v1/event-types/{slug}/hosts", h.RequireAuth(h.SetEventTypeHosts)) testEmailRL := RateLimit(10, time.Minute) From dbc4b80a669ae209892acd5601b510a392269106 Mon Sep 17 00:00:00 2001 From: Sean Dean <254259913+distronode-com@users.noreply.github.com> Date: Fri, 4 Sep 2026 03:54:52 -0400 Subject: [PATCH 2/2] test(event-types): cover the email templates and the price/inactive rules (#17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Independent re-check of this branch against the issue's requirement list turned up two things asserted only in passing, so they now have tests of their own: - copiesEveryEmailTemplateColumn — the issue names "email/reminder templates" as one of the datasets that must be copied. They are columns on event_types rather than a table, so the big test only sampled the three that appear in the API shape; this compares all nine msg_*/subj_* columns on the copied row against the source, and re-checks the reminder schedule. - isCreatedInactiveWithThePriceIntact — the two requirements that pull in opposite directions (the copy must not be live, its price must not be reset), checked on the row rather than only in the response, plus that the source is left untouched. Verified: gofmt -l . empty, go vet ./... clean, go test ./... exit 0 (26 packages, no failures). The nine duplicate tests pass individually. This branch touches no locale file, no template and nothing under internal/slots, so it stays independent of the #20 branch. --- internal/handler/event_type_duplicate_test.go | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/internal/handler/event_type_duplicate_test.go b/internal/handler/event_type_duplicate_test.go index 6bcdf33..c448586 100644 --- a/internal/handler/event_type_duplicate_test.go +++ b/internal/handler/event_type_duplicate_test.go @@ -477,6 +477,107 @@ func TestDuplicateEventType_rollsBackWhenAChildCopyFails(t *testing.T) { } } +// TestDuplicateEventType_copiesEveryEmailTemplateColumn covers the child dataset the issue +// names as "email/reminder templates". They are columns on event_types rather than a table +// of their own, so this checks all nine on the copied row rather than sampling the ones +// that happen to be in the API shape. +func TestDuplicateEventType_copiesEveryEmailTemplateColumn(t *testing.T) { + h, database, ownerKey, ownerID := setupWorkspaceWithDB(t) + memberID := seedMember(t, database, "u2", "member@example.com") + seedRichEventType(t, database, ownerID, memberID) + + rec := duplicate(t, h, ownerKey, "intro-call") + if rec.Code != http.StatusCreated { + t.Fatalf("duplicate: got %d; want 201 — %s", rec.Code, rec.Body.String()) + } + var got duplicateResponse + json.Unmarshal(rec.Body.Bytes(), &got) //nolint:errcheck + + columns := []string{ + "msg_confirmation", "msg_cancellation", "msg_reschedule", "msg_reminder", "msg_greeting", + "subj_confirmation", "subj_cancellation", "subj_reschedule", "subj_reminder", + } + for _, col := range columns { + var src, copied sql.NullString + // #nosec G202 -- col comes from the literal list above, never from input. + if err := database.QueryRow(`SELECT ` + col + ` FROM event_types WHERE id = 'src'`).Scan(&src); err != nil { + t.Fatalf("read source %s: %v", col, err) + } + // #nosec G202 -- same literal list. + if err := database.QueryRow(`SELECT `+col+` FROM event_types WHERE id = ?`, got.ID).Scan(&copied); err != nil { + t.Fatalf("read copied %s: %v", col, err) + } + if !src.Valid { + t.Fatalf("fixture bug: source %s is NULL, so this proves nothing", col) + } + if copied.String != src.String { + t.Errorf("%s: copy has %q; source has %q", col, copied.String, src.String) + } + } + + // The reminder schedule is the other half of that requirement, and it IS a table. + var hours []int + rows, err := database.Query( + `SELECT hours_before FROM event_type_reminders WHERE event_type_id = ? ORDER BY hours_before`, got.ID) + if err != nil { + t.Fatalf("read copied reminders: %v", err) + } + defer rows.Close() + for rows.Next() { + var hb int + if err := rows.Scan(&hb); err != nil { + t.Fatalf("scan reminder: %v", err) + } + hours = append(hours, hb) + } + if len(hours) != 2 || hours[0] != 2 || hours[1] != 24 { + t.Errorf("copied reminder schedule: got %v; want [2 24]", hours) + } +} + +// TestDuplicateEventType_isCreatedInactiveWithThePriceIntact pins the two rules from the +// issue that pull in opposite directions: the copy must NOT be live, and its price must +// NOT be reset. Zeroing the price is the dangerous default — the operator recognises the +// event type, publishes it, and starts giving away a paid meeting. +func TestDuplicateEventType_isCreatedInactiveWithThePriceIntact(t *testing.T) { + h, database, ownerKey, ownerID := setupWorkspaceWithDB(t) + mustExec(t, database, ` + INSERT INTO event_types (id, user_id, slug, name, duration_minutes, is_active, price_cents, currency) + VALUES ('src', ?, 'paid-call', 'Paid Call', 30, 1, 12500, 'gbp')`, ownerID) + + rec := duplicate(t, h, ownerKey, "paid-call") + if rec.Code != http.StatusCreated { + t.Fatalf("duplicate: got %d; want 201 — %s", rec.Code, rec.Body.String()) + } + var got duplicateResponse + json.Unmarshal(rec.Body.Bytes(), &got) //nolint:errcheck + if got.IsActive { + t.Error("response says the copy is active; a copy must never be publishable on creation") + } + + var isActive, priceCents int + var currency string + if err := database.QueryRow( + `SELECT is_active, price_cents, currency FROM event_types WHERE id = ?`, got.ID). + Scan(&isActive, &priceCents, ¤cy); err != nil { + t.Fatalf("read copy: %v", err) + } + if isActive != 0 { + t.Error("is_active on the copied row is 1; want 0") + } + if priceCents != 12500 || currency != "gbp" { + t.Errorf("price on the copied row: got %d %q; want 12500 \"gbp\"", priceCents, currency) + } + // And the source is untouched — the copy is a new row, not a move. + if err := database.QueryRow( + `SELECT is_active, price_cents FROM event_types WHERE id = 'src'`).Scan(&isActive, &priceCents); err != nil { + t.Fatalf("read source: %v", err) + } + if isActive != 1 || priceCents != 12500 { + t.Errorf("the source changed: is_active=%d price_cents=%d; want 1/12500", isActive, priceCents) + } +} + // TestDuplicateEventType_handlesEveryEventTypeColumn is a drift gate, not a behaviour // test. DuplicateEventType names its columns explicitly (SQLite has no "copy every // column" form), so a migration that adds one would silently leave it out of every