Skip to content

Duplicate an event type, with every child dataset - #22

Open
distronode-com wants to merge 2 commits into
Calnode:mainfrom
distronode-com:feat/duplicate-event-type
Open

Duplicate an event type, with every child dataset#22
distronode-com wants to merge 2 commits into
Calnode:mainfrom
distronode-com:feat/duplicate-event-type

Conversation

@distronode-com

Copy link
Copy Markdown

Fixes #17

Adds POST /v1/event-types/{slug}/duplicate and a Duplicate action on each row of the
event-types list.

What it copies

One transaction covers the event-type row plus every child dataset:

data table notes
intake questions event_type_questions label, type, options JSON, required, position
host assignments event_type_hosts user, role, priority
availability rules availability_rules event-type-specific rows only (see below)
reminder schedule event_type_reminders every hours_before
email subjects and notes event_types.msg_* / subj_* columns, so the row copy carries them

Bookings are not copied. A copied booking would be a meeting nobody agreed to, carrying a
live manage link, and it would distort both the per-invitee max_active_bookings cap and
the double-booking guard.

What is deliberately not inherited

  • is_active is forced to 0. A copy exists to be edited; publishing one the moment it
    is created is the accident this endpoint has to avoid.
  • archived_at is cleared. Archiving describes the original's lifecycle, and an
    archived copy would be invisible in the default list. The copy is inactive anyway, so
    nothing becomes bookable by clearing it.
  • slug is regenerated as intro-call-copy, then intro-call-copy-2,
    intro-call-copy-3, … because event_types.slug is UNIQUE.

Everything else is copied verbatim, price_cents and currency included. Zeroing a
copied price is the more dangerous default: the operator sees a familiar event type,
publishes it, and starts taking free bookings for a paid meeting.

The name is copied verbatim too. Unlike the slug it is not required to be unique, and
it is booker-facing (it appears on the booking page, in confirmation emails and in the
calendar invite), so appending "(copy)" would risk leaking an internal artefact into
customer-facing copy. In the list the copy is distinguishable by its slug and by being
inactive. Happy to switch to Name (copy) if you would rather have it.

Implementation notes

  • The row copy is one INSERT … SELECT. Every inherited column is named once and its
    value never passes through the Go process, so it cannot be zeroed or truncated in
    transit; the only literals in the statement are the four overridden fields.
  • A drift gate keeps that column list honest. SQLite has no "copy every column" form,
    so the list is explicit — which means a future migration could silently leave a new
    column out of every copy, with no failing test anywhere near it.
    TestDuplicateEventType_handlesEveryEventTypeColumn reads
    pragma_table_info('event_types') and fails unless each column appears either in the
    copy or in a documented not-inherited list, and it also fails when a listed column
    disappears.
  • Single-connection rule (ARCHITECTURE §4). Every child row is read into a slice, with
    each cursor closed, before anything is written — a write issued while a cursor is open
    would wait on the connection that cursor holds and deadlock as context deadline exceeded. Same shape as loadHostSchedule and the calendar reconciler.
  • Global availability rules are not copied. A rule with event_type_id IS NULL is the
    host's global default and already applies to the copy. Copying it would promote a global
    rule into an event-specific one, after which 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 handing them a copy they could not then edit would be worse than a 404.
  • The slug is chosen inside the transaction, so the uniqueness check and the INSERT
    that consumes its answer are atomic. The check is deliberately not owner-scoped, because
    the constraint is not: a slug taken by another user's event type is still taken. After
    50 attempts it gives up with a 409 rather than looping.
  • Response is 201 with the copy in the same shape as CreateEventType, so the admin UI
    can send the operator straight into the new event type's editor if you ever want that.

UI

A ghost icon button plus Tooltip in the row action group, between Settings and Archive,
shown only for event types the viewer owns — the same pattern the archive and delete
actions already use. On success it toasts the generated slug and notes that the copy is
inactive, then reloads the list. The button is disabled while its own request is in
flight, so a double click cannot produce two copies.

Testing

go test ./... green; gofmt -l . empty; go vet ./... clean. Frontend
pnpm test:visual passes (5 tests), as does pnpm check (941 files, 0 errors).

New tests in internal/handler/event_type_duplicate_test.go:

  • copiesTheRowAndEveryChildDataset — seeds a non-default value in every copied column
    and one row in each child table, then asserts each dataset arrived, that child rows got
    fresh ids rather than being shared, that the global availability rule was left alone,
    and that no booking followed.
  • slugIsUniqueAcrossTheInstance-copy already owned by another user, so the first
    copy lands on -copy-2 and the second on -copy-3.
  • archivedSourceProducesALiveDraft — archived source, un-archived and inactive copy.
  • onlyTheOwnerMayDuplicate — an assigned host gets 404 and no copy is created.
  • unknownSlugIs404.
  • rollsBackWhenAChildCopyFails — a trigger forces the question insert to fail; asserts
    500 and that no event type, host, rule or reminder row survives. 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.
  • handlesEveryEventTypeColumn — the drift gate described above.

…alnode#17)

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 <slug>-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).
…ules (Calnode#17)

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 Calnode#20 branch.
@distronode-com

Copy link
Copy Markdown
Author

I have read the CLA Document and I hereby sign the CLA

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

All contributors have signed the CLA. ✅
Posted by the CLA Assistant Lite bot.

@distronode-com
distronode-com force-pushed the feat/duplicate-event-type branch from bbee92f to dbc4b80 Compare September 4, 2026 08:00
github-actions Bot added a commit that referenced this pull request Sep 4, 2026

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes Initial review of duplicate event type (API + list UI + tests).

  • Transactional duplicate endpointPOST /v1/event-types/{slug}/duplicate copies the row via INSERT … SELECT and every child dataset (questions, hosts, event-scoped availability, reminders) in one tx; bookings and global availability rules stay out.
  • Safe defaults — copy is inactive, un-archived, under a generated -copy/-copy-N slug; price and email template columns are preserved; owner-only like PATCH/DELETE.
  • Admin list action — ghost icon + Tooltip Duplicate control on owned rows, with in-flight disable and a toast that names the new inactive slug.
  • Test coverage — child fidelity, cross-user slug uniqueness, authz, rollback, event_types column drift gate, and explicit price/inactive + all nine email template columns.

Pullfrog  | View workflow run | Using Grok𝕏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Duplicate an event type

1 participant