feat: delete space
All checks were successful
Deploy / build-and-deploy (push) Successful in 2m34s

This commit is contained in:
juancwu 2026-03-14 19:11:11 -04:00
commit 6871b1f8c6
No known key found for this signature in database
5 changed files with 134 additions and 0 deletions

View file

@ -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())

View file

@ -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
}

View file

@ -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)

View file

@ -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)
}

View file

@ -193,6 +193,91 @@ templ SpaceSettingsPage(space *model.Space, members []*model.SpaceMemberWithProf
}
}
}
// Danger Zone (owner only)
if isOwner {
@card.Card() {
@card.Header() {
@card.Title() {
<div class="flex items-center gap-2 text-destructive">
@icon.TriangleAlert(icon.Props{Class: "size-5"})
Danger Zone
</div>
}
@card.Description() {
Irreversible and destructive actions.
}
}
@card.Content() {
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium">Delete this space</p>
<p class="text-sm text-muted-foreground">Once deleted, all data in this space will be permanently removed.</p>
</div>
{{ 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.
}
}
<form
hx-delete={ "/app/spaces/" + space.ID }
hx-swap="none"
class="space-y-4 px-6 pb-6"
>
@csrf.Token()
<div class="space-y-2">
@label.Label(label.Props{For: "confirmation_name"}) {
Type <span class="font-semibold">{ space.Name }</span> 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",
},
})
</div>
<div class="flex justify-end gap-2">
@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
}
</div>
</form>
}
}
</div>
}
}
}
</div>
@dialog.Script()
}