Skip to content

Commit 1939d07

Browse files
committed
refactor: dynamic addon registration at startup instead of migrations
Replace migration-based addon registration with a builtinAddons registry in service.go. All built-in addons are upserted on startup via SeedInstalledAddons(), so adding a new addon only requires adding an entry to the builtinAddons slice — no SQL migration needed. This makes the addon system future-proof for user-uploaded addons and eliminates migration numbering conflicts when multiple branches add addons concurrently. - Added Upsert method to AddonRepository (INSERT ON DUPLICATE KEY UPDATE) - Replaced installedAddons map with builtinAddons slice carrying full metadata - Added SeedInstalledAddons to AddonService interface, called in routes.go - Removed 000009_add_npcs_addon migration (NPCs now in builtinAddons) https://claude.ai/code/session_01WJEjfBqjZaGatHiXXXDupo
1 parent 4406faa commit 1939d07

6 files changed

Lines changed: 116 additions & 21 deletions

File tree

db/migrations/000009_add_npcs_addon.down.sql

Lines changed: 0 additions & 1 deletion
This file was deleted.

db/migrations/000009_add_npcs_addon.up.sql

Lines changed: 0 additions & 4 deletions
This file was deleted.

internal/app/routes.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -872,6 +872,11 @@ func (a *App) RegisterRoutes() {
872872
// Addons plugin: extension framework with per-campaign enable/disable toggles.
873873
addonRepo := addons.NewAddonRepository(a.DB)
874874
addonService := addons.NewAddonService(addonRepo)
875+
// Register all built-in addons on startup so new addons appear
876+
// automatically without requiring SQL migrations.
877+
if err := addonService.SeedInstalledAddons(context.Background()); err != nil {
878+
slog.Error("failed to seed built-in addons", slog.String("error", err.Error()))
879+
}
875880
addonHandler := addons.NewHandler(addonService)
876881
addons.RegisterAdminRoutes(adminGroup, addonHandler)
877882
addons.RegisterCampaignRoutes(e, addonHandler, campaignService, authService)

internal/plugins/addons/repository.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ type AddonRepository interface {
2222
Delete(ctx context.Context, id int) error
2323
UpdateStatus(ctx context.Context, id int, status AddonStatus) error
2424

25+
// Upsert inserts or updates an addon by slug (used for startup registration).
26+
Upsert(ctx context.Context, addon *Addon) error
27+
2528
// Per-campaign addon settings.
2629
ListForCampaign(ctx context.Context, campaignID string) ([]CampaignAddon, error)
2730
EnableForCampaign(ctx context.Context, campaignID string, addonID int, userID string) error
@@ -149,6 +152,29 @@ func (r *addonRepository) Create(ctx context.Context, addon *Addon) error {
149152
return nil
150153
}
151154

155+
// Upsert inserts a new addon or updates an existing one matched by slug.
156+
// Used during startup to register all built-in addons without requiring
157+
// separate SQL migrations for each addon.
158+
func (r *addonRepository) Upsert(ctx context.Context, addon *Addon) error {
159+
query := `INSERT INTO addons (slug, name, description, version, category, status, icon, author)
160+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
161+
ON DUPLICATE KEY UPDATE
162+
name = VALUES(name),
163+
description = VALUES(description),
164+
version = VALUES(version),
165+
icon = VALUES(icon),
166+
author = VALUES(author)`
167+
168+
_, err := r.db.ExecContext(ctx, query,
169+
addon.Slug, addon.Name, addon.Description, addon.Version,
170+
addon.Category, addon.Status, addon.Icon, addon.Author,
171+
)
172+
if err != nil {
173+
return fmt.Errorf("upserting addon %s: %w", addon.Slug, err)
174+
}
175+
return nil
176+
}
177+
152178
// Update modifies an addon's metadata.
153179
func (r *addonRepository) Update(ctx context.Context, addon *Addon) error {
154180
query := `UPDATE addons SET name = ?, description = ?, version = ?, status = ?, icon = ?

internal/plugins/addons/service.go

Lines changed: 81 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ type AddonService interface {
2626
Delete(ctx context.Context, id int) error
2727
UpdateStatus(ctx context.Context, id int, status AddonStatus) error
2828

29+
// SeedInstalledAddons upserts all built-in addons at startup.
30+
SeedInstalledAddons(ctx context.Context) error
31+
2932
// Per-campaign controls (campaign owner).
3033
ListForCampaign(ctx context.Context, campaignID string) ([]CampaignAddon, error)
3134
EnableForCampaign(ctx context.Context, campaignID string, addonID int, userID string) error
@@ -117,29 +120,91 @@ var validStatuses = map[AddonStatus]bool{
117120
StatusDeprecated: true,
118121
}
119122

120-
// installedAddons lists addon slugs that have real backing code in the
121-
// codebase. Only installed addons can be activated by admins or enabled
122-
// by campaign owners. Update this set as new addons are built.
123-
var installedAddons = map[string]bool{
124-
"sync-api": true,
125-
"notes": true,
126-
"attributes": true,
127-
"calendar": true,
128-
"maps": true,
129-
"sessions": true,
130-
"timeline": true,
131-
"media-gallery": true,
132-
"npcs": true,
133-
"dnd5e": true,
134-
"pathfinder2e": true,
135-
"drawsteel": true,
123+
// addonDef describes a built-in addon that ships with the codebase.
124+
// Used for automatic registration at startup — no migration needed.
125+
type addonDef struct {
126+
Slug string
127+
Name string
128+
Description string
129+
Version string
130+
Category AddonCategory
131+
Status AddonStatus
132+
Icon string
133+
Author string
134+
}
135+
136+
// builtinAddons is the canonical registry of all addons that ship with
137+
// Chronicle. Adding a new addon here is all that's needed — the startup
138+
// seeder will upsert it into the database automatically. No migration required.
139+
var builtinAddons = []addonDef{
140+
// Game systems (content packs).
141+
{Slug: "dnd5e", Name: "D&D 5th Edition", Description: "Reference data, stat blocks, and tooltips for Dungeons & Dragons 5th Edition", Version: "0.1.0", Category: CategorySystem, Status: StatusActive, Icon: "fa-dragon", Author: "Chronicle"},
142+
{Slug: "pathfinder2e", Name: "Pathfinder 2nd Edition", Description: "Reference data and tooltips for Pathfinder 2nd Edition", Version: "0.1.0", Category: CategorySystem, Status: StatusActive, Icon: "fa-shield-halved", Author: "Chronicle"},
143+
{Slug: "drawsteel", Name: "Draw Steel", Description: "Reference data for the Draw Steel RPG system", Version: "0.1.0", Category: CategorySystem, Status: StatusActive, Icon: "fa-swords", Author: "Chronicle"},
144+
145+
// Plugins (feature apps).
146+
{Slug: "calendar", Name: "Calendar", Description: "Custom fantasy calendar with configurable months, weekdays, moons, seasons, and events. Link events to entities for timeline tracking.", Version: "0.1.0", Category: CategoryPlugin, Status: StatusActive, Icon: "fa-calendar-days", Author: "Chronicle"},
147+
{Slug: "maps", Name: "Interactive Maps", Description: "Leaflet.js map viewer with entity pins and layer support", Version: "0.1.0", Category: CategoryPlugin, Status: StatusActive, Icon: "fa-map", Author: "Chronicle"},
148+
{Slug: "media-gallery", Name: "Media Gallery", Description: "Campaign media management — upload, browse, and organize images.", Version: "0.1.0", Category: CategoryPlugin, Status: StatusActive, Icon: "fa-images", Author: "Chronicle"},
149+
{Slug: "timeline", Name: "Timeline", Description: "Interactive visual timelines with zoom levels, entity grouping, and calendar integration.", Version: "0.1.0", Category: CategoryPlugin, Status: StatusActive, Icon: "fa-timeline", Author: "Chronicle"},
150+
{Slug: "sessions", Name: "Sessions", Description: "Track game sessions with scheduling, linked entities, and RSVP.", Version: "0.1.0", Category: CategoryPlugin, Status: StatusActive, Icon: "fa-calendar-check", Author: "Chronicle"},
151+
{Slug: "npcs", Name: "NPC Gallery", Description: "Browse and reveal character entities as NPCs for your players.", Version: "1.0.0", Category: CategoryPlugin, Status: StatusActive, Icon: "fa-users", Author: "Chronicle"},
152+
153+
// Integrations.
154+
{Slug: "sync-api", Name: "Sync API", Description: "Secure REST API for external tool integration (Foundry VTT, Roll20, etc.)", Version: "0.1.0", Category: CategoryIntegration, Status: StatusActive, Icon: "fa-arrows-rotate", Author: "Chronicle"},
155+
156+
// Widgets.
157+
{Slug: "notes", Name: "Notes", Description: "Floating notebook panel for personal and shared campaign notes. Includes checklists, color coding, version history, and edit locking.", Version: "0.1.0", Category: CategoryWidget, Status: StatusActive, Icon: "fa-book", Author: "Chronicle"},
158+
{Slug: "attributes", Name: "Attributes", Description: "Custom attribute fields on entity pages (e.g. race, alignment, HP). When disabled, attribute panels are hidden.", Version: "0.1.0", Category: CategoryWidget, Status: StatusActive, Icon: "fa-sliders", Author: "Chronicle"},
159+
160+
// Planned (no backing code yet).
161+
{Slug: "player-notes", Name: "Player Notes", Description: "Collaborative note-taking block for entity pages.", Version: "0.1.0", Category: CategoryWidget, Status: StatusPlanned, Icon: "fa-sticky-note", Author: "Chronicle"},
162+
{Slug: "family-tree", Name: "Family Tree", Description: "Visual family/org tree diagram from entity relations", Version: "0.1.0", Category: CategoryWidget, Status: StatusPlanned, Icon: "fa-sitemap", Author: "Chronicle"},
163+
{Slug: "dice-roller", Name: "Dice Roller", Description: "In-app dice rolling with formula support and history", Version: "0.1.0", Category: CategoryWidget, Status: StatusPlanned, Icon: "fa-dice-d20", Author: "Chronicle"},
164+
}
165+
166+
// installedAddons is derived from builtinAddons for quick lookup.
167+
var installedAddons map[string]bool
168+
169+
func init() {
170+
installedAddons = make(map[string]bool, len(builtinAddons))
171+
for _, a := range builtinAddons {
172+
if a.Status == StatusActive {
173+
installedAddons[a.Slug] = true
174+
}
175+
}
136176
}
137177

138178
// IsInstalled reports whether an addon slug has backing code in the codebase.
139179
func IsInstalled(slug string) bool {
140180
return installedAddons[slug]
141181
}
142182

183+
// SeedInstalledAddons upserts all built-in addons into the database.
184+
// Called once at startup so new addons are registered automatically
185+
// without requiring SQL migrations.
186+
func (s *addonService) SeedInstalledAddons(ctx context.Context) error {
187+
for _, def := range builtinAddons {
188+
desc := def.Description
189+
author := def.Author
190+
addon := &Addon{
191+
Slug: def.Slug,
192+
Name: def.Name,
193+
Description: &desc,
194+
Version: def.Version,
195+
Category: def.Category,
196+
Status: def.Status,
197+
Icon: def.Icon,
198+
Author: &author,
199+
}
200+
if err := s.repo.Upsert(ctx, addon); err != nil {
201+
return fmt.Errorf("seeding addon %s: %w", def.Slug, err)
202+
}
203+
}
204+
slog.Info("built-in addons registered", slog.Int("count", len(builtinAddons)))
205+
return nil
206+
}
207+
143208
// Create registers a new addon in the global registry.
144209
func (s *addonService) Create(ctx context.Context, input CreateAddonInput) (*Addon, error) {
145210
slug := strings.TrimSpace(input.Slug)

internal/plugins/addons/service_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ func (m *mockAddonRepo) Create(ctx context.Context, addon *Addon) error {
6363
return nil
6464
}
6565

66+
func (m *mockAddonRepo) Upsert(ctx context.Context, addon *Addon) error {
67+
return nil
68+
}
69+
6670
func (m *mockAddonRepo) Update(ctx context.Context, addon *Addon) error {
6771
if m.updateFn != nil {
6872
return m.updateFn(ctx, addon)

0 commit comments

Comments
 (0)