From 6871b1f8c6474d8495c32c9e3f584a734b560565 Mon Sep 17 00:00:00 2001 From: juancwu Date: Sat, 14 Mar 2026 19:11:11 -0400 Subject: [PATCH] feat: delete space --- internal/handler/space_settings_handler.go | 36 +++++++++ internal/repository/space.go | 7 ++ internal/routes/routes.go | 1 + internal/service/space.go | 5 ++ internal/ui/pages/app_space_settings.templ | 85 ++++++++++++++++++++++ 5 files changed, 134 insertions(+) diff --git a/internal/handler/space_settings_handler.go b/internal/handler/space_settings_handler.go index 797810b..b0239e4 100644 --- a/internal/handler/space_settings_handler.go +++ b/internal/handler/space_settings_handler.go @@ -271,6 +271,42 @@ func (h *SpaceSettingsHandler) CreateInvite(w http.ResponseWriter, r *http.Reque })) } +func (h *SpaceSettingsHandler) DeleteSpace(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + user := ctxkeys.User(r.Context()) + + space, err := h.spaceService.GetSpace(spaceID) + if err != nil { + http.Error(w, "Space not found", http.StatusNotFound) + return + } + + if space.OwnerID != user.ID { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + if err := r.ParseForm(); err != nil { + ui.RenderError(w, r, "Bad Request", http.StatusUnprocessableEntity) + return + } + + confirmationName := r.FormValue("confirmation_name") + if confirmationName != space.Name { + ui.RenderError(w, r, "Space name does not match", http.StatusUnprocessableEntity) + return + } + + if err := h.spaceService.DeleteSpace(spaceID); err != nil { + slog.Error("failed to delete space", "error", err, "space_id", spaceID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + w.Header().Set("HX-Redirect", "/app/spaces") + w.WriteHeader(http.StatusOK) +} + func (h *SpaceSettingsHandler) JoinSpace(w http.ResponseWriter, r *http.Request) { token := r.PathValue("token") user := ctxkeys.User(r.Context()) diff --git a/internal/repository/space.go b/internal/repository/space.go index 3c06671..a1b4f6b 100644 --- a/internal/repository/space.go +++ b/internal/repository/space.go @@ -23,6 +23,7 @@ type SpaceRepository interface { GetMembers(spaceID string) ([]*model.SpaceMemberWithProfile, error) UpdateName(spaceID, name string) error UpdateTimezone(spaceID, timezone string) error + Delete(spaceID string) error } type spaceRepository struct { @@ -135,3 +136,9 @@ func (r *spaceRepository) UpdateTimezone(spaceID, timezone string) error { _, err := r.db.Exec(query, timezone, time.Now(), spaceID) return err } + +func (r *spaceRepository) Delete(spaceID string) error { + query := `DELETE FROM spaces WHERE id = $1;` + _, err := r.db.Exec(query, spaceID) + return err +} diff --git a/internal/routes/routes.go b/internal/routes/routes.go index 0a23b2c..9a4fbef 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -154,6 +154,7 @@ func SetupRoutes(a *app.App) http.Handler { spaceRouteLimited(mux, sa, cl, "DELETE /app/spaces/{spaceID}/invites/{token}", spaceSettings.CancelInvite) spaceRoute(mux, sa, "GET /app/spaces/{spaceID}/settings/invites", spaceSettings.GetPendingInvites) spaceRouteLimited(mux, sa, cl, "POST /app/spaces/{spaceID}/invites", spaceSettings.CreateInvite) + spaceRouteLimited(mux, sa, cl, "DELETE /app/spaces/{spaceID}", spaceSettings.DeleteSpace) // Loans spaceRoute(mux, sa, "GET /app/spaces/{spaceID}/loans", space.LoansPage) diff --git a/internal/service/space.go b/internal/service/space.go index 5ed2ada..bd16b5c 100644 --- a/internal/service/space.go +++ b/internal/service/space.go @@ -118,3 +118,8 @@ func (s *SpaceService) UpdateSpaceTimezone(spaceID, timezone string) error { } return s.spaceRepo.UpdateTimezone(spaceID, timezone) } + +// DeleteSpace permanently deletes a space and all its associated data. +func (s *SpaceService) DeleteSpace(spaceID string) error { + return s.spaceRepo.Delete(spaceID) +} diff --git a/internal/ui/pages/app_space_settings.templ b/internal/ui/pages/app_space_settings.templ index b360098..22cfcab 100644 --- a/internal/ui/pages/app_space_settings.templ +++ b/internal/ui/pages/app_space_settings.templ @@ -193,6 +193,91 @@ templ SpaceSettingsPage(space *model.Space, members []*model.SpaceMemberWithProf } } } + // Danger Zone (owner only) + if isOwner { + @card.Card() { + @card.Header() { + @card.Title() { +
+ @icon.TriangleAlert(icon.Props{Class: "size-5"}) + Danger Zone +
+ } + @card.Description() { + Irreversible and destructive actions. + } + } + @card.Content() { +
+
+

Delete this space

+

Once deleted, all data in this space will be permanently removed.

+
+ {{ deleteDialogID := "delete-space-dialog-" + space.ID }} + @dialog.Dialog(dialog.Props{ID: deleteDialogID, DisableClickAway: true}) { + @dialog.Trigger() { + @button.Button(button.Props{ + Variant: button.VariantDestructive, + Type: button.TypeButton, + }) { + Delete Space + } + } + @dialog.Content() { + @dialog.Header() { + @dialog.Title() { + Delete space + } + @dialog.Description() { + This action is permanent and cannot be undone. All data including expenses, budgets, shopping lists, and members will be permanently deleted. + } + } +
+ @csrf.Token() +
+ @label.Label(label.Props{For: "confirmation_name"}) { + Type { space.Name } to confirm + } + @input.Input(input.Props{ + Name: "confirmation_name", + Placeholder: space.Name, + Attributes: templ.Attributes{ + "id": "confirmation_name", + "autocomplete": "off", + "_": "on input if my value equals '" + space.Name + "' remove @disabled from #delete-space-confirm else add @disabled to #delete-space-confirm", + }, + }) +
+
+ @dialog.Close() { + @button.Button(button.Props{ + Variant: button.VariantOutline, + Type: button.TypeButton, + }) { + Cancel + } + } + @button.Button(button.Props{ + Variant: button.VariantDestructive, + Attributes: templ.Attributes{ + "id": "delete-space-confirm", + "disabled": true, + }, + }) { + Delete Space + } +
+
+ } + } +
+ } + } + } @dialog.Script() }