Add board duplication to a new board (roomDup)#237
Conversation
Add a roomDup server method that copies a board's pages and objects into a brand new room, leaving the original untouched. Exposed three ways: - a "duplicate board" button in the room toolbar (opens the copy in a new tab); - a /clone/:roomId link that clones and redirects, so each visitor gets their own independent copy; - a clone link surfaced in the "Link to this room/board" dialog. Motivating use case: hand out a link to a prepared problem board so each student works on their own copy. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Greptile SummaryThis PR adds board duplication to Cocreate: a new
Confidence Score: 4/5The core duplication logic is sound and consistent with the existing The
Important Files Changed
Sequence DiagramsequenceDiagram
participant User
participant Browser
participant CloneRoute as /clone/:roomId
participant Server as Meteor Server
participant DB as MongoDB
User->>Browser: Visit /clone/:roomId
Browser->>CloneRoute: Render CloneRoom component
CloneRoute->>Server: Meteor.call('roomDup', roomId)
Server->>DB: Rooms.findOne(roomId)
DB-->>Server: room document
Server->>DB: Rooms.insert(newRoom)
DB-->>Server: newRoomId
loop for each page in room.pages
Server->>DB: Pages.findOne(pageId)
Server->>Server: pageNew(page) → newPageId
Server->>DB: Rooms.update $push newPageId
loop for each object on page
Server->>DB: "Objects.find({room, page})"
Server->>Server: objectNew(obj) with new room/page
Server->>DB: Objects.insert(obj)
end
end
Server-->>CloneRoute: newRoomId
CloneRoute->>Browser: navigate('/r/newRoomId', replace:true)
Browser-->>User: New board opened
Note over User,Browser: Toolbar button (dupRoom)
User->>Browser: Click Duplicate Board
Browser->>Server: Meteor.call('roomDup', currentRoom().id)
Server-->>Browser: newRoomId
Browser->>User: window.open('/r/newRoomId')
Reviews (1): Last reviewed commit: "Add board duplication to a new board (ro..." | Re-trigger Greptile |
| ### don't render anything while redirecting ### | ||
| <Show when={error()}> | ||
| <div class="modal error"> | ||
| <h1>Failed to Clone Board</h1> | ||
| <p>Perhaps the board link is wrong, or you're disconnected from the network?</p> | ||
| <pre>{error().toString()}</pre> | ||
| <p><a href={Meteor.absoluteUrl()}>Create a new blank board instead</a></p> | ||
| </div> | ||
| </Show> |
There was a problem hiding this comment.
No loading state while clone is in progress
The component renders nothing (<Show when={error()}> is the only output) until either an error occurs or navigation fires. For a board with many pages and objects, roomDup can take several seconds; during that time the visitor sees a completely blank page with no spinner or message. They may assume the link is broken and refresh, which would trigger a second clone.
Consider rendering a brief "Cloning board…" placeholder outside the <Show> block so the user knows something is happening.
| click: -> | ||
| Meteor.call 'roomDup', currentRoom().id, (error, roomId) -> | ||
| if error? | ||
| return console.error "Failed to duplicate room on server: #{error}" | ||
| window.open "/r/#{roomId}" |
There was a problem hiding this comment.
dupRoom errors are only logged to the console, not shown to the user
If roomDup fails (e.g., the server throws "Invalid room ID"), console.error is called but nothing appears in the UI. The user clicks the button, nothing opens, and has no idea whether a duplicate was created or why it failed. A brief alert or modal message on error would make the failure visible.
| click: -> | ||
| Meteor.call 'roomDup', currentRoom().id, (error, roomId) -> | ||
| if error? | ||
| return console.error "Failed to duplicate room on server: #{error}" | ||
| window.open "/r/#{roomId}" |
There was a problem hiding this comment.
dupRoom button has no in-flight guard against double-invocation
Each click fires a fresh Meteor.call 'roomDup'; rapid double-clicking (or impatient users) would create multiple duplicate rooms and open several tabs. There is no visual loading state and the button stays active during the async call. Disabling the button (or tracking a loading signal) while the call is in flight would prevent this.
| roomDup: (roomId) -> | ||
| check roomId, String | ||
| return if @isSimulation | ||
| room = checkRoom roomId | ||
| sourcePages = room.pages ? [] | ||
| delete room._id | ||
| delete room.created | ||
| room.created = new Date | ||
| room.pages = [] | ||
| newRoomId = Rooms.insert room | ||
| for pageId in sourcePages | ||
| page = Pages.findOne pageId | ||
| continue unless page? | ||
| delete page._id | ||
| delete page.created | ||
| page.room = newRoomId | ||
| newPageId = Meteor.apply 'pageNew', [page] | ||
| Objects.find | ||
| room: roomId | ||
| page: pageId | ||
| .forEach (obj) -> | ||
| delete obj._id | ||
| delete obj.created | ||
| delete obj.updated | ||
| obj.room = newRoomId | ||
| obj.page = newPageId | ||
| Meteor.call 'objectNew', obj | ||
| newRoomId |
There was a problem hiding this comment.
roomDup is publicly callable with no rate limiting
Any visitor (anonymous or not) who knows — or guesses — a room ID can call roomDup to create arbitrarily many new rooms, each populated with every object from the source board. A tightly-looped script could flood the rooms, pages, and objects collections quickly. None of the other methods in this file have rate limiting either, so this is consistent with the existing security model, but the cost-per-call is significantly higher here than for a typical single-document write. Worth considering whether a per-IP or per-connection call-rate limit (DDPRateLimiter) is appropriate for this method.
Proposal
This is a proposal — happy to adjust the API, naming, UX, or scope (or split it up) to fit how you'd like the feature to work. No worries if it's not a direction you want to take.
What & why
Cocreate can duplicate a page within a board (
pageDup). This adds the ability to duplicate an entire board — all pages and all objects — into a brand new board with its own URL, leaving the original untouched.Motivating use case: an instructor sets up a problem on a board and hands out a link; each student who clicks it gets their own independent copy to work on and export, without touching the original or each other's work.
Changes
lib/rooms.coffee— newroomDup(roomId)method that mirrorspageDup's structure: creates a new room, then copies every page (in order) and its objects, repointingroom/page. Server-only (return if @isSimulation) so the caller just awaits the result — works even when the visitor hasn't subscribed to the source board.client/CloneRoom.coffee+client/App.coffee— new/clone/:roomIdroute. Visiting it clones the board and redirects to the copy. This is the shareable "handout" link.client/tools/room.coffee— a "duplicate board" button in the room toolbar (opens the copy in a new tab), plus a clone link surfaced in the existing "Link to this room/board" dialog.CHANGELOG.md— user-facing entry.Testing
npm run lintpasses (eslint + markdownlint).meteorserver and exercisedroomDupend-to-end: with a multi-page board containing objects, the copy has fresh page IDs, all objects copied and repointed to the new room/pages, page order preserved, and the original board unchanged. Also smoke-tested the toolbar button, the/clone/:roomIdlink, and the dialog clone link in the browser.Open questions
/clone/:roomId) and method name (roomDup) are my best guess at fitting existing conventions — easy to change.