mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 21:39:01 +00:00
0fd5d0cdfb
* fix(store): swap plainto_tsquery → websearch_to_tsquery for PG FTS (BUG-842)
`TestListItems_FTS_HyphenatedSearchTerm/task-five` has been failing on
every Go (PostgreSQL) CI run because `plainto_tsquery('english',
'task-five')` doesn't match the asciihword lexeme(s) the english parser
produces for an indexed `task-five-distinctive`. The result is that
every PG full-text search for hyphenated terms returns zero rows.
`websearch_to_tsquery` (Postgres 11+) is purpose-built for arbitrary
user input and tokenizes hyphenated terms the same way `to_tsvector`
does for the indexed document, so the query intersects the index
correctly. Swapped in three spots in the postgres dialect — FTSMatch,
FTSSnippet, FTSRank — and updated the caller-side comments that
referenced plainto_tsquery. SQLite path is unchanged: it goes through
items_fts MATCH with sanitizeFTSQuery, never through these methods.
* fix(server): drain background goroutines on Stop() (BUG-842)
`TestAdminBillingStats_SidecarSidecarError_DegradesToLocalOnly` (and
other server tests) have been flaking on the Go (PostgreSQL) CI runner
with `TempDir RemoveAll cleanup: directory not empty`. Root cause:
several request handlers spawned bare `go func() { ... }()` goroutines
that touched the SQLite WAL DB after the test function returned.
testServer's t.Cleanup closed the store but had no way to drain those
goroutines first, so a fire-and-forget WAL write could re-create the
`-wal`/`-shm` files between Close() and t.TempDir's RemoveAll.
Add a Server.bg sync.WaitGroup, a Server.goAsync helper that wraps a
WaitGroup-tracked goroutine, and a Server.Stop() that blocks until
every goAsync closure has finished. Convert the four known
fire-and-forget sites to goAsync:
- middleware_auth.go (TouchUserActivity)
- handlers_auth.go (password reset email)
- handlers_cloud.go (stripe_processed_events pruning)
- handlers_members.go (workspace invitation email)
Wire `srv.Stop()` into both testServer (server_test.go) and
newMetricsTestServer (metrics_auth_test.go) so cleanup order is
Stop → Close → TempDir RemoveAll. Add
TestServer_Stop_DrainsBackgroundGoroutines to pin the contract: a
goAsync goroutine must block Stop until it returns.
* fix(store): correct PG FTS hyphenation via OR-combined plainto_tsquery (BUG-842)
The previous attempt swapped plainto_tsquery → websearch_to_tsquery,
which was wrong: websearch_to_tsquery treats `-` as a NEGATION operator
(Google-style), so `task-five` becomes `task & !five` and the search
returns 0 rows for the same reason as before. This commit reverts the
swap and applies the actual fix.
PG's english parser indexes `task-five-distinctive` as
`{task-five-distinct, task, five, distinct}` — the asciihword AND its
parts. plainto_tsquery applied to the partial query `task-five`
produces `task-fiv & task & five`: the stemmed asciihword for the
PARTIAL query (`task-fiv`) is NOT in the vector, so the AND fails.
Replacing the hyphen with a space makes plainto emit `task & five`,
which DOES match — but doing that unconditionally breaks `BUG-842`-
style queries: PG indexes the `-842` suffix as a negative-number
lexeme, so `plainto_tsquery('BUG-842')` matches via `-842`, while
`plainto_tsquery('BUG 842')` searches for `842` and misses.
The fix ORs the two query variants together so the search vector is
matched against either the raw user query OR its hyphen-as-space form.
Both `task-five` (against `task-five-distinctive`) and `BUG-842`
(against `BUG-842 fix the cleanup race`) hit. Verified locally against
postgres:17-alpine via PAD_TEST_POSTGRES_URL — both 10x stress and
race-detector runs are green.
Surfaces:
- dialect.go: FTSMatch / FTSSnippet / FTSRank now consume TWO
placeholders each in the PG dialect.
- items.go: listItemsFTS PG branch + SearchItems PG branch update
args to pass (raw, sanitized) for every PG `?` placeholder.
- search.go: SearchItems main / count / facets PG branches updated
likewise. New sanitizePGFTSQuery helper alongside sanitizeFTSQuery.
- documents.go: ListDocuments PG branch updated.
Tests:
- TestListItems_FTS_HyphenatedSearchTerm extended with a `BUG-842`
case to pin the OR-combined logic — naive hyphen-stripping would
silently regress this.
- New TestSanitizePGFTSQuery unit test.
* chore: gofmt 11 files with import-order issues (BUG-842 PR cleanup)
The Go (SQLite) CI job has been failing on `main` (and every PR built
against it) because golangci-lint flags 11 files whose third-party
imports are intermixed with internal imports — the import-grouping
rule that gofmt enforces. None of these were introduced by the
BUG-842 PR; they're pre-existing on main. The PR can't go green
without this cleanup, though, so it's bundled here.
Pure mechanical change — `gofmt -w <files>` only re-orders import
groups; no logic changes. Files touched:
cmd/pad/configure.go
cmd/pad/main.go
internal/cli/format.go
internal/server/handlers_admin_invitations.go
internal/server/handlers_admin_users.go
internal/server/handlers_grants.go
internal/server/handlers_share_links.go
internal/server/handlers_stars.go
internal/server/middleware_auth.go
internal/store/store.go
internal/store/store_test.go
After this commit `gofmt -l ./cmd ./internal` returns clean.
147 lines
3.9 KiB
Go
147 lines
3.9 KiB
Go
package server
|
|
|
|
import (
|
|
"database/sql"
|
|
"log/slog"
|
|
"net/http"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/email"
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
"github.com/PerpetualSoftware/pad/internal/store"
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// handleAdminListInvitations returns all pending invitations across all workspaces.
|
|
// GET /api/v1/admin/invitations?q=search
|
|
func (s *Server) handleAdminListInvitations(w http.ResponseWriter, r *http.Request) {
|
|
if !requireAdmin(w, r) {
|
|
return
|
|
}
|
|
|
|
query := r.URL.Query().Get("q")
|
|
invitations, err := s.store.ListPendingInvitationsAdmin(query)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
if invitations == nil {
|
|
invitations = []store.AdminInvitation{}
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"invitations": invitations,
|
|
})
|
|
}
|
|
|
|
// handleAdminResendInvitation revokes the old invitation and creates a fresh one
|
|
// with a new code, then sends the invitation email.
|
|
// POST /api/v1/admin/invitations/{invID}/resend
|
|
func (s *Server) handleAdminResendInvitation(w http.ResponseWriter, r *http.Request) {
|
|
if !requireAdmin(w, r) {
|
|
return
|
|
}
|
|
|
|
invID := chi.URLParam(r, "invID")
|
|
old, err := s.store.GetInvitation(invID)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
if old == nil || old.AcceptedAt != nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "Pending invitation not found")
|
|
return
|
|
}
|
|
|
|
// Delete old invitation — abort if it's already gone (accepted or revoked concurrently)
|
|
if err := s.store.DeleteInvitationAdmin(invID); err != nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "Invitation is no longer pending")
|
|
return
|
|
}
|
|
|
|
caller := currentUser(r)
|
|
inviterID := old.InvitedBy
|
|
if caller != nil {
|
|
inviterID = caller.ID
|
|
}
|
|
|
|
inv, err := s.store.CreateInvitation(old.WorkspaceID, old.Email, old.Role, inviterID)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
|
|
// Send email
|
|
joinURL := ""
|
|
if s.baseURL != "" {
|
|
joinURL = s.baseURL + "/join/" + inv.Code
|
|
}
|
|
|
|
if s.email != nil && joinURL != "" {
|
|
// Respect email opt-out preferences
|
|
if optedOut, err := s.store.IsEmailOptedOut(inv.Email); err == nil && optedOut {
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"ok": true,
|
|
"method": "code",
|
|
"message": "Invitation recreated but email not sent (recipient opted out)",
|
|
})
|
|
return
|
|
}
|
|
|
|
inviterName := "An administrator"
|
|
wsName := "a workspace"
|
|
if caller != nil {
|
|
inviterName = caller.Name
|
|
}
|
|
if ws, err := s.store.GetWorkspaceByID(old.WorkspaceID); err == nil && ws != nil {
|
|
wsName = ws.Name
|
|
}
|
|
unsubURL := email.UnsubscribeURL(s.baseURL, inv.Email, s.unsubscribeSecret())
|
|
if err := s.email.SendInvitation(r.Context(), inv.Email, inviterName, wsName, joinURL, unsubURL); err != nil {
|
|
slog.Error("failed to resend invitation email", "error", err, "email", inv.Email)
|
|
writeError(w, http.StatusInternalServerError, "email_failed", "Invitation recreated but failed to send email")
|
|
return
|
|
}
|
|
}
|
|
|
|
s.logAuditEvent(models.ActionMemberInvited, r, auditMeta(map[string]string{
|
|
"email": inv.Email,
|
|
"role": inv.Role,
|
|
"workspace": old.WorkspaceID,
|
|
"resend": "true",
|
|
}))
|
|
|
|
method := "code"
|
|
if s.email != nil && joinURL != "" {
|
|
method = "email"
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"ok": true,
|
|
"method": method,
|
|
"message": "Invitation resent to " + inv.Email,
|
|
})
|
|
}
|
|
|
|
// handleAdminDeleteInvitation revokes a pending invitation.
|
|
// DELETE /api/v1/admin/invitations/{invID}
|
|
func (s *Server) handleAdminDeleteInvitation(w http.ResponseWriter, r *http.Request) {
|
|
if !requireAdmin(w, r) {
|
|
return
|
|
}
|
|
|
|
invID := chi.URLParam(r, "invID")
|
|
if err := s.store.DeleteInvitationAdmin(invID); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
writeError(w, http.StatusNotFound, "not_found", "Pending invitation not found")
|
|
return
|
|
}
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"ok": true,
|
|
"message": "Invitation revoked",
|
|
})
|
|
}
|