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() { +
Delete this space
+Once deleted, all data in this space will be permanently removed.
+