Skip to content

Commit 834d696

Browse files
committed
fix: session Mark Complete button + feat: two-dashboard architecture
Fix session completion bug: hx-vals sends form-encoded data but handler expects JSON. Replaced with Chronicle.apiFetch() onclick, also fixing XSS risk from session name interpolation into JSON string. Add owner dashboard (GET /campaigns/:id/dashboard) visible only to campaign owners. Campaign page remains the public-facing dashboard. Both dashboards independently customizable via the dashboard editor in the Customization Hub. New migration adds owner_dashboard_layout JSON column to campaigns table. https://claude.ai/code/session_01JmHn8AGZVkKPAvHDgR67Hu
1 parent d97eac6 commit 834d696

12 files changed

Lines changed: 372 additions & 34 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ALTER TABLE campaigns DROP COLUMN owner_dashboard_layout;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ALTER TABLE campaigns ADD COLUMN owner_dashboard_layout JSON DEFAULT NULL;

internal/plugins/campaigns/customize.templ

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,9 +146,9 @@ templ dashboardEditorTab(cc *CampaignContext, csrfToken string) {
146146
<div class="h-full flex flex-col">
147147
<div class="px-6 pt-4 pb-2 shrink-0">
148148
<div class="max-w-3xl mx-auto">
149-
<h2 class="text-base font-semibold text-fg mb-0.5">Campaign Dashboard</h2>
149+
<h2 class="text-base font-semibold text-fg mb-0.5">Campaign Page</h2>
150150
<p class="text-xs text-fg-secondary">
151-
Customize the layout of your campaign's main dashboard page.
151+
Customize the layout of your campaign's main page visible to all members.
152152
</p>
153153
</div>
154154
</div>
@@ -160,6 +160,22 @@ templ dashboardEditorTab(cc *CampaignContext, csrfToken string) {
160160
data-csrf-token={ csrfToken }
161161
></div>
162162
</div>
163+
<div class="px-6 pt-6 pb-2 shrink-0 border-t border-edge mt-4">
164+
<div class="max-w-3xl mx-auto">
165+
<h2 class="text-base font-semibold text-fg mb-0.5">Owner Dashboard</h2>
166+
<p class="text-xs text-fg-secondary">
167+
Customize your private management dashboard (visible to owner and co-DMs only).
168+
</p>
169+
</div>
170+
</div>
171+
<div class="flex-1 overflow-y-auto px-6 py-2">
172+
<div
173+
data-widget="dashboard-editor"
174+
data-endpoint={ fmt.Sprintf("/campaigns/%s/owner-dashboard-layout", cc.Campaign.ID) }
175+
data-campaign-id={ cc.Campaign.ID }
176+
data-csrf-token={ csrfToken }
177+
></div>
178+
</div>
163179
</div>
164180
}
165181

internal/plugins/campaigns/handler.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -812,6 +812,76 @@ func (h *Handler) ResetDashboardLayout(c echo.Context) error {
812812
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
813813
}
814814

815+
// --- Owner Dashboard ---
816+
817+
// OwnerDashboard renders the owner-only management dashboard (GET /campaigns/:id/dashboard).
818+
func (h *Handler) OwnerDashboard(c echo.Context) error {
819+
cc := GetCampaignContext(c)
820+
if cc == nil {
821+
return apperror.NewMissingContext()
822+
}
823+
824+
var recentEntities []RecentEntity
825+
if h.recentLister != nil {
826+
recentEntities, _ = h.recentLister.ListRecentForDashboard(
827+
c.Request().Context(), cc.Campaign.ID, int(cc.MemberRole), auth.GetUserID(c), 8,
828+
)
829+
}
830+
831+
csrfToken := middleware.GetCSRFToken(c)
832+
return middleware.Render(c, http.StatusOK, OwnerDashboardPage(cc, recentEntities, csrfToken))
833+
}
834+
835+
// GetOwnerDashboardLayout returns the owner dashboard layout JSON (GET /campaigns/:id/owner-dashboard-layout).
836+
func (h *Handler) GetOwnerDashboardLayout(c echo.Context) error {
837+
cc := GetCampaignContext(c)
838+
if cc == nil {
839+
return apperror.NewMissingContext()
840+
}
841+
842+
layout, err := h.service.GetOwnerDashboardLayout(c.Request().Context(), cc.Campaign.ID)
843+
if err != nil {
844+
return err
845+
}
846+
847+
return c.JSON(http.StatusOK, layout)
848+
}
849+
850+
// UpdateOwnerDashboardLayout saves the owner dashboard layout (PUT /campaigns/:id/owner-dashboard-layout).
851+
func (h *Handler) UpdateOwnerDashboardLayout(c echo.Context) error {
852+
cc := GetCampaignContext(c)
853+
if cc == nil {
854+
return apperror.NewMissingContext()
855+
}
856+
857+
var layout DashboardLayout
858+
if err := json.NewDecoder(c.Request().Body).Decode(&layout); err != nil {
859+
return apperror.NewBadRequest("invalid JSON body")
860+
}
861+
862+
if err := h.service.UpdateOwnerDashboardLayout(c.Request().Context(), cc.Campaign.ID, &layout); err != nil {
863+
return err
864+
}
865+
866+
h.logAudit(c, cc.Campaign.ID, "owner_dashboard_layout_updated", nil)
867+
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
868+
}
869+
870+
// ResetOwnerDashboardLayout removes the custom owner dashboard layout (DELETE /campaigns/:id/owner-dashboard-layout).
871+
func (h *Handler) ResetOwnerDashboardLayout(c echo.Context) error {
872+
cc := GetCampaignContext(c)
873+
if cc == nil {
874+
return apperror.NewMissingContext()
875+
}
876+
877+
if err := h.service.ResetOwnerDashboardLayout(c.Request().Context(), cc.Campaign.ID); err != nil {
878+
return err
879+
}
880+
881+
h.logAudit(c, cc.Campaign.ID, "owner_dashboard_layout_reset", nil)
882+
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
883+
}
884+
815885
// --- View As Player Toggle ---
816886

817887
// viewAsPlayerCookie is the cookie name for the "view as player" display toggle.

internal/plugins/campaigns/model.go

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,9 @@ type Campaign struct {
9898
Settings string `json:"settings"`
9999
BackdropPath *string `json:"backdrop_path,omitempty"`
100100
SidebarConfig string `json:"sidebar_config"`
101-
DashboardLayout *string `json:"dashboard_layout,omitempty"` // JSON layout; nil = use hardcoded default.
102-
CreatedBy string `json:"created_by"`
101+
DashboardLayout *string `json:"dashboard_layout,omitempty"` // JSON layout; nil = use hardcoded default.
102+
OwnerDashboardLayout *string `json:"owner_dashboard_layout,omitempty"` // Owner-only dashboard layout; nil = use default.
103+
CreatedBy string `json:"created_by"`
103104
CreatedAt time.Time `json:"created_at"`
104105
UpdatedAt time.Time `json:"updated_at"`
105106
}
@@ -277,6 +278,23 @@ func (c *Campaign) ParseDashboardLayout() *DashboardLayout {
277278
return &layout
278279
}
279280

281+
// ParseOwnerDashboardLayout parses the campaign's owner_dashboard_layout JSON
282+
// into a DashboardLayout struct. Returns nil if the column is NULL (use default).
283+
func (c *Campaign) ParseOwnerDashboardLayout() *DashboardLayout {
284+
if c.OwnerDashboardLayout == nil || *c.OwnerDashboardLayout == "" {
285+
return nil
286+
}
287+
var layout DashboardLayout
288+
if err := json.Unmarshal([]byte(*c.OwnerDashboardLayout), &layout); err != nil {
289+
slog.Warn("failed to parse owner dashboard layout, using default",
290+
slog.String("campaign_id", c.ID),
291+
slog.String("error", err.Error()),
292+
)
293+
return nil
294+
}
295+
return &layout
296+
}
297+
280298
// CampaignSettings holds campaign-level configuration stored as JSON in
281299
// the campaigns.settings column. Accent color, display preferences, etc.
282300
type CampaignSettings struct {

internal/plugins/campaigns/repository.go

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ type CampaignRepository interface {
5252
// Pass nil to revert to the hardcoded default dashboard.
5353
UpdateDashboardLayout(ctx context.Context, campaignID string, layoutJSON *string) error
5454

55+
// UpdateOwnerDashboardLayout updates only the owner_dashboard_layout JSON column.
56+
// Pass nil to revert to the hardcoded default owner dashboard.
57+
UpdateOwnerDashboardLayout(ctx context.Context, campaignID string, layoutJSON *string) error
58+
5559
// TransferOwnership atomically transfers campaign ownership from one user
5660
// to another within a database transaction.
5761
TransferOwnership(ctx context.Context, campaignID, fromUserID, toUserID string) error
@@ -75,12 +79,12 @@ func NewCampaignRepository(db *sql.DB) CampaignRepository {
7579

7680
// Create inserts a new campaign row.
7781
func (r *campaignRepository) Create(ctx context.Context, campaign *Campaign) error {
78-
query := `INSERT INTO campaigns (id, name, slug, description, is_public, settings, backdrop_path, sidebar_config, dashboard_layout, created_by, created_at, updated_at)
79-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
82+
query := `INSERT INTO campaigns (id, name, slug, description, is_public, settings, backdrop_path, sidebar_config, dashboard_layout, owner_dashboard_layout, created_by, created_at, updated_at)
83+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
8084

8185
_, err := r.db.ExecContext(ctx, query,
8286
campaign.ID, campaign.Name, campaign.Slug, campaign.Description, campaign.IsPublic,
83-
campaign.Settings, campaign.BackdropPath, campaign.SidebarConfig, campaign.DashboardLayout,
87+
campaign.Settings, campaign.BackdropPath, campaign.SidebarConfig, campaign.DashboardLayout, campaign.OwnerDashboardLayout,
8488
campaign.CreatedBy, campaign.CreatedAt, campaign.UpdatedAt,
8589
)
8690
if err != nil {
@@ -91,13 +95,13 @@ func (r *campaignRepository) Create(ctx context.Context, campaign *Campaign) err
9195

9296
// FindByID retrieves a campaign by its UUID.
9397
func (r *campaignRepository) FindByID(ctx context.Context, id string) (*Campaign, error) {
94-
query := `SELECT id, name, slug, description, is_public, settings, backdrop_path, sidebar_config, dashboard_layout, created_by, created_at, updated_at
98+
query := `SELECT id, name, slug, description, is_public, settings, backdrop_path, sidebar_config, dashboard_layout, owner_dashboard_layout, created_by, created_at, updated_at
9599
FROM campaigns WHERE id = ?`
96100

97101
c := &Campaign{}
98102
err := r.db.QueryRowContext(ctx, query, id).Scan(
99103
&c.ID, &c.Name, &c.Slug, &c.Description, &c.IsPublic,
100-
&c.Settings, &c.BackdropPath, &c.SidebarConfig, &c.DashboardLayout,
104+
&c.Settings, &c.BackdropPath, &c.SidebarConfig, &c.DashboardLayout, &c.OwnerDashboardLayout,
101105
&c.CreatedBy, &c.CreatedAt, &c.UpdatedAt,
102106
)
103107
if errors.Is(err, sql.ErrNoRows) {
@@ -111,13 +115,13 @@ func (r *campaignRepository) FindByID(ctx context.Context, id string) (*Campaign
111115

112116
// FindBySlug retrieves a campaign by its URL slug.
113117
func (r *campaignRepository) FindBySlug(ctx context.Context, slug string) (*Campaign, error) {
114-
query := `SELECT id, name, slug, description, is_public, settings, backdrop_path, sidebar_config, dashboard_layout, created_by, created_at, updated_at
118+
query := `SELECT id, name, slug, description, is_public, settings, backdrop_path, sidebar_config, dashboard_layout, owner_dashboard_layout, created_by, created_at, updated_at
115119
FROM campaigns WHERE slug = ?`
116120

117121
c := &Campaign{}
118122
err := r.db.QueryRowContext(ctx, query, slug).Scan(
119123
&c.ID, &c.Name, &c.Slug, &c.Description, &c.IsPublic,
120-
&c.Settings, &c.BackdropPath, &c.SidebarConfig, &c.DashboardLayout,
124+
&c.Settings, &c.BackdropPath, &c.SidebarConfig, &c.DashboardLayout, &c.OwnerDashboardLayout,
121125
&c.CreatedBy, &c.CreatedAt, &c.UpdatedAt,
122126
)
123127
if errors.Is(err, sql.ErrNoRows) {
@@ -161,7 +165,7 @@ func (r *campaignRepository) ListByUser(ctx context.Context, userID string, opts
161165
var c Campaign
162166
if err := rows.Scan(
163167
&c.ID, &c.Name, &c.Slug, &c.Description, &c.IsPublic,
164-
&c.Settings, &c.BackdropPath, &c.SidebarConfig, &c.DashboardLayout,
168+
&c.Settings, &c.BackdropPath, &c.SidebarConfig, &c.DashboardLayout, &c.OwnerDashboardLayout,
165169
&c.CreatedBy, &c.CreatedAt, &c.UpdatedAt,
166170
); err != nil {
167171
return nil, 0, fmt.Errorf("scanning campaign row: %w", err)
@@ -179,7 +183,7 @@ func (r *campaignRepository) ListAll(ctx context.Context, opts ListOptions) ([]C
179183
return nil, 0, fmt.Errorf("counting all campaigns: %w", err)
180184
}
181185

182-
query := `SELECT id, name, slug, description, is_public, settings, backdrop_path, sidebar_config, dashboard_layout, created_by, created_at, updated_at
186+
query := `SELECT id, name, slug, description, is_public, settings, backdrop_path, sidebar_config, dashboard_layout, owner_dashboard_layout, created_by, created_at, updated_at
183187
FROM campaigns ORDER BY updated_at DESC LIMIT ? OFFSET ?`
184188

185189
rows, err := r.db.QueryContext(ctx, query, opts.PerPage, opts.Offset())
@@ -193,7 +197,7 @@ func (r *campaignRepository) ListAll(ctx context.Context, opts ListOptions) ([]C
193197
var c Campaign
194198
if err := rows.Scan(
195199
&c.ID, &c.Name, &c.Slug, &c.Description, &c.IsPublic,
196-
&c.Settings, &c.BackdropPath, &c.SidebarConfig, &c.DashboardLayout,
200+
&c.Settings, &c.BackdropPath, &c.SidebarConfig, &c.DashboardLayout, &c.OwnerDashboardLayout,
197201
&c.CreatedBy, &c.CreatedAt, &c.UpdatedAt,
198202
); err != nil {
199203
return nil, 0, fmt.Errorf("scanning campaign row: %w", err)
@@ -206,7 +210,7 @@ func (r *campaignRepository) ListAll(ctx context.Context, opts ListOptions) ([]C
206210
// ListPublic returns public campaigns ordered by most recently updated.
207211
// Used for the public landing page to showcase discoverable campaigns.
208212
func (r *campaignRepository) ListPublic(ctx context.Context, limit int) ([]Campaign, error) {
209-
query := `SELECT id, name, slug, description, is_public, settings, backdrop_path, sidebar_config, dashboard_layout, created_by, created_at, updated_at
213+
query := `SELECT id, name, slug, description, is_public, settings, backdrop_path, sidebar_config, dashboard_layout, owner_dashboard_layout, created_by, created_at, updated_at
210214
FROM campaigns WHERE is_public = 1
211215
ORDER BY updated_at DESC LIMIT ?`
212216

@@ -221,7 +225,7 @@ func (r *campaignRepository) ListPublic(ctx context.Context, limit int) ([]Campa
221225
var c Campaign
222226
if err := rows.Scan(
223227
&c.ID, &c.Name, &c.Slug, &c.Description, &c.IsPublic,
224-
&c.Settings, &c.BackdropPath, &c.SidebarConfig, &c.DashboardLayout,
228+
&c.Settings, &c.BackdropPath, &c.SidebarConfig, &c.DashboardLayout, &c.OwnerDashboardLayout,
225229
&c.CreatedBy, &c.CreatedAt, &c.UpdatedAt,
226230
); err != nil {
227231
return nil, fmt.Errorf("scanning public campaign row: %w", err)
@@ -353,6 +357,23 @@ func (r *campaignRepository) UpdateDashboardLayout(ctx context.Context, campaign
353357
return nil
354358
}
355359

360+
// UpdateOwnerDashboardLayout updates only the owner_dashboard_layout JSON for a campaign.
361+
// Pass nil to revert to the hardcoded default owner dashboard.
362+
func (r *campaignRepository) UpdateOwnerDashboardLayout(ctx context.Context, campaignID string, layoutJSON *string) error {
363+
result, err := r.db.ExecContext(ctx,
364+
`UPDATE campaigns SET owner_dashboard_layout = ?, updated_at = NOW() WHERE id = ?`,
365+
layoutJSON, campaignID,
366+
)
367+
if err != nil {
368+
return fmt.Errorf("updating owner dashboard layout: %w", err)
369+
}
370+
rows, _ := result.RowsAffected()
371+
if rows == 0 {
372+
return apperror.NewNotFound("campaign not found")
373+
}
374+
return nil
375+
}
376+
356377
// --- Membership ---
357378

358379
// AddMember inserts a new campaign membership row.

internal/plugins/campaigns/routes.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ func RegisterRoutes(e *echo.Echo, h *Handler, svc CampaignService, authSvc auth.
5858
cg.PUT("/dashboard-layout", h.UpdateDashboardLayout, RequireRole(RoleOwner))
5959
cg.DELETE("/dashboard-layout", h.ResetDashboardLayout, RequireRole(RoleOwner))
6060

61+
// Owner dashboard (Owner + Co-DM).
62+
cg.GET("/dashboard", h.OwnerDashboard, RequireRole(RoleOwner))
63+
cg.GET("/owner-dashboard-layout", h.GetOwnerDashboardLayout, RequireRole(RoleOwner))
64+
cg.PUT("/owner-dashboard-layout", h.UpdateOwnerDashboardLayout, RequireRole(RoleOwner))
65+
cg.DELETE("/owner-dashboard-layout", h.ResetOwnerDashboardLayout, RequireRole(RoleOwner))
66+
6167
// Backdrop and branding (Owner only).
6268
cg.POST("/backdrop", h.UploadBackdrop, RequireRole(RoleOwner))
6369
cg.DELETE("/backdrop", h.RemoveBackdrop, RequireRole(RoleOwner))

0 commit comments

Comments
 (0)