feat(inbox): scope all inbox queries by workspace_id
Inbox items were previously queried only by recipient, which leaked data across workspaces. All list/count/batch operations now filter by workspace_id from the X-Workspace-ID header. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
42f72371bd
commit
4126073229
4 changed files with 75 additions and 35 deletions
|
|
@ -18,6 +18,7 @@ import (
|
|||
func inboxItemsForRecipient(t *testing.T, queries *db.Queries, recipientID string) []db.ListInboxItemsRow {
|
||||
t.Helper()
|
||||
items, err := queries.ListInboxItems(context.Background(), db.ListInboxItemsParams{
|
||||
WorkspaceID: util.ParseUUID(testWorkspaceID),
|
||||
RecipientType: "member",
|
||||
RecipientID: util.ParseUUID(recipientID),
|
||||
Limit: 100,
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ func (h *Handler) ListInbox(w http.ResponseWriter, r *http.Request) {
|
|||
if !ok {
|
||||
return
|
||||
}
|
||||
workspaceID := r.Header.Get("X-Workspace-ID")
|
||||
|
||||
limit := 50
|
||||
offset := 0
|
||||
|
|
@ -106,6 +107,7 @@ func (h *Handler) ListInbox(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
items, err := h.Queries.ListInboxItems(r.Context(), db.ListInboxItemsParams{
|
||||
WorkspaceID: parseUUID(workspaceID),
|
||||
RecipientType: "member",
|
||||
RecipientID: parseUUID(userID),
|
||||
Limit: int32(limit),
|
||||
|
|
@ -173,8 +175,10 @@ func (h *Handler) CountUnreadInbox(w http.ResponseWriter, r *http.Request) {
|
|||
if !ok {
|
||||
return
|
||||
}
|
||||
workspaceID := r.Header.Get("X-Workspace-ID")
|
||||
|
||||
count, err := h.Queries.CountUnreadInbox(r.Context(), db.CountUnreadInboxParams{
|
||||
WorkspaceID: parseUUID(workspaceID),
|
||||
RecipientType: "member",
|
||||
RecipientID: parseUUID(userID),
|
||||
})
|
||||
|
|
@ -191,15 +195,18 @@ func (h *Handler) MarkAllInboxRead(w http.ResponseWriter, r *http.Request) {
|
|||
if !ok {
|
||||
return
|
||||
}
|
||||
workspaceID := r.Header.Get("X-Workspace-ID")
|
||||
|
||||
count, err := h.Queries.MarkAllInboxRead(r.Context(), parseUUID(userID))
|
||||
count, err := h.Queries.MarkAllInboxRead(r.Context(), db.MarkAllInboxReadParams{
|
||||
WorkspaceID: parseUUID(workspaceID),
|
||||
RecipientID: parseUUID(userID),
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to mark all inbox read")
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("inbox: mark all read", append(logger.RequestAttrs(r), "user_id", userID, "count", count)...)
|
||||
workspaceID := r.Header.Get("X-Workspace-ID")
|
||||
h.publish(protocol.EventInboxBatchRead, workspaceID, "member", userID, map[string]any{
|
||||
"recipient_id": userID,
|
||||
"count": count,
|
||||
|
|
@ -213,15 +220,18 @@ func (h *Handler) ArchiveAllInbox(w http.ResponseWriter, r *http.Request) {
|
|||
if !ok {
|
||||
return
|
||||
}
|
||||
workspaceID := r.Header.Get("X-Workspace-ID")
|
||||
|
||||
count, err := h.Queries.ArchiveAllInbox(r.Context(), parseUUID(userID))
|
||||
count, err := h.Queries.ArchiveAllInbox(r.Context(), db.ArchiveAllInboxParams{
|
||||
WorkspaceID: parseUUID(workspaceID),
|
||||
RecipientID: parseUUID(userID),
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to archive all inbox")
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("inbox: archive all", append(logger.RequestAttrs(r), "user_id", userID, "count", count)...)
|
||||
workspaceID := r.Header.Get("X-Workspace-ID")
|
||||
h.publish(protocol.EventInboxBatchArchived, workspaceID, "member", userID, map[string]any{
|
||||
"recipient_id": userID,
|
||||
"count": count,
|
||||
|
|
@ -235,15 +245,18 @@ func (h *Handler) ArchiveAllReadInbox(w http.ResponseWriter, r *http.Request) {
|
|||
if !ok {
|
||||
return
|
||||
}
|
||||
workspaceID := r.Header.Get("X-Workspace-ID")
|
||||
|
||||
count, err := h.Queries.ArchiveAllReadInbox(r.Context(), parseUUID(userID))
|
||||
count, err := h.Queries.ArchiveAllReadInbox(r.Context(), db.ArchiveAllReadInboxParams{
|
||||
WorkspaceID: parseUUID(workspaceID),
|
||||
RecipientID: parseUUID(userID),
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to archive all read inbox")
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("inbox: archive all read", append(logger.RequestAttrs(r), "user_id", userID, "count", count)...)
|
||||
workspaceID := r.Header.Get("X-Workspace-ID")
|
||||
h.publish(protocol.EventInboxBatchArchived, workspaceID, "member", userID, map[string]any{
|
||||
"recipient_id": userID,
|
||||
"count": count,
|
||||
|
|
@ -257,15 +270,18 @@ func (h *Handler) ArchiveCompletedInbox(w http.ResponseWriter, r *http.Request)
|
|||
if !ok {
|
||||
return
|
||||
}
|
||||
workspaceID := r.Header.Get("X-Workspace-ID")
|
||||
|
||||
count, err := h.Queries.ArchiveCompletedInbox(r.Context(), parseUUID(userID))
|
||||
count, err := h.Queries.ArchiveCompletedInbox(r.Context(), db.ArchiveCompletedInboxParams{
|
||||
WorkspaceID: parseUUID(workspaceID),
|
||||
RecipientID: parseUUID(userID),
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to archive completed inbox")
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("inbox: archive completed", append(logger.RequestAttrs(r), "user_id", userID, "count", count)...)
|
||||
workspaceID := r.Header.Get("X-Workspace-ID")
|
||||
h.publish(protocol.EventInboxBatchArchived, workspaceID, "member", userID, map[string]any{
|
||||
"recipient_id": userID,
|
||||
"count": count,
|
||||
|
|
|
|||
|
|
@ -13,11 +13,16 @@ import (
|
|||
|
||||
const archiveAllInbox = `-- name: ArchiveAllInbox :execrows
|
||||
UPDATE inbox_item SET archived = true
|
||||
WHERE recipient_type = 'member' AND recipient_id = $1 AND archived = false
|
||||
WHERE workspace_id = $1 AND recipient_type = 'member' AND recipient_id = $2 AND archived = false
|
||||
`
|
||||
|
||||
func (q *Queries) ArchiveAllInbox(ctx context.Context, recipientID pgtype.UUID) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, archiveAllInbox, recipientID)
|
||||
type ArchiveAllInboxParams struct {
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
RecipientID pgtype.UUID `json:"recipient_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) ArchiveAllInbox(ctx context.Context, arg ArchiveAllInboxParams) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, archiveAllInbox, arg.WorkspaceID, arg.RecipientID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
|
@ -26,11 +31,16 @@ func (q *Queries) ArchiveAllInbox(ctx context.Context, recipientID pgtype.UUID)
|
|||
|
||||
const archiveAllReadInbox = `-- name: ArchiveAllReadInbox :execrows
|
||||
UPDATE inbox_item SET archived = true
|
||||
WHERE recipient_type = 'member' AND recipient_id = $1 AND read = true AND archived = false
|
||||
WHERE workspace_id = $1 AND recipient_type = 'member' AND recipient_id = $2 AND read = true AND archived = false
|
||||
`
|
||||
|
||||
func (q *Queries) ArchiveAllReadInbox(ctx context.Context, recipientID pgtype.UUID) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, archiveAllReadInbox, recipientID)
|
||||
type ArchiveAllReadInboxParams struct {
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
RecipientID pgtype.UUID `json:"recipient_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) ArchiveAllReadInbox(ctx context.Context, arg ArchiveAllReadInboxParams) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, archiveAllReadInbox, arg.WorkspaceID, arg.RecipientID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
|
@ -38,13 +48,18 @@ func (q *Queries) ArchiveAllReadInbox(ctx context.Context, recipientID pgtype.UU
|
|||
}
|
||||
|
||||
const archiveCompletedInbox = `-- name: ArchiveCompletedInbox :execrows
|
||||
UPDATE inbox_item SET archived = true
|
||||
WHERE recipient_type = 'member' AND recipient_id = $1 AND archived = false
|
||||
AND issue_id IN (SELECT id FROM issue WHERE status IN ('done', 'cancelled'))
|
||||
UPDATE inbox_item i SET archived = true
|
||||
WHERE i.workspace_id = $1 AND i.recipient_type = 'member' AND i.recipient_id = $2 AND i.archived = false
|
||||
AND i.issue_id IN (SELECT id FROM issue WHERE status IN ('done', 'cancelled'))
|
||||
`
|
||||
|
||||
func (q *Queries) ArchiveCompletedInbox(ctx context.Context, recipientID pgtype.UUID) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, archiveCompletedInbox, recipientID)
|
||||
type ArchiveCompletedInboxParams struct {
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
RecipientID pgtype.UUID `json:"recipient_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) ArchiveCompletedInbox(ctx context.Context, arg ArchiveCompletedInboxParams) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, archiveCompletedInbox, arg.WorkspaceID, arg.RecipientID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
|
@ -82,16 +97,17 @@ func (q *Queries) ArchiveInboxItem(ctx context.Context, id pgtype.UUID) (InboxIt
|
|||
|
||||
const countUnreadInbox = `-- name: CountUnreadInbox :one
|
||||
SELECT count(*) FROM inbox_item
|
||||
WHERE recipient_type = $1 AND recipient_id = $2 AND read = false AND archived = false
|
||||
WHERE workspace_id = $1 AND recipient_type = $2 AND recipient_id = $3 AND read = false AND archived = false
|
||||
`
|
||||
|
||||
type CountUnreadInboxParams struct {
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
RecipientType string `json:"recipient_type"`
|
||||
RecipientID pgtype.UUID `json:"recipient_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) CountUnreadInbox(ctx context.Context, arg CountUnreadInboxParams) (int64, error) {
|
||||
row := q.db.QueryRow(ctx, countUnreadInbox, arg.RecipientType, arg.RecipientID)
|
||||
row := q.db.QueryRow(ctx, countUnreadInbox, arg.WorkspaceID, arg.RecipientType, arg.RecipientID)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
|
|
@ -188,12 +204,13 @@ SELECT i.id, i.workspace_id, i.recipient_type, i.recipient_id, i.type, i.severit
|
|||
iss.status as issue_status
|
||||
FROM inbox_item i
|
||||
LEFT JOIN issue iss ON iss.id = i.issue_id
|
||||
WHERE i.recipient_type = $1 AND i.recipient_id = $2 AND i.archived = false
|
||||
WHERE i.workspace_id = $1 AND i.recipient_type = $2 AND i.recipient_id = $3 AND i.archived = false
|
||||
ORDER BY i.created_at DESC
|
||||
LIMIT $3 OFFSET $4
|
||||
LIMIT $4 OFFSET $5
|
||||
`
|
||||
|
||||
type ListInboxItemsParams struct {
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
RecipientType string `json:"recipient_type"`
|
||||
RecipientID pgtype.UUID `json:"recipient_id"`
|
||||
Limit int32 `json:"limit"`
|
||||
|
|
@ -221,6 +238,7 @@ type ListInboxItemsRow struct {
|
|||
|
||||
func (q *Queries) ListInboxItems(ctx context.Context, arg ListInboxItemsParams) ([]ListInboxItemsRow, error) {
|
||||
rows, err := q.db.Query(ctx, listInboxItems,
|
||||
arg.WorkspaceID,
|
||||
arg.RecipientType,
|
||||
arg.RecipientID,
|
||||
arg.Limit,
|
||||
|
|
@ -263,11 +281,16 @@ func (q *Queries) ListInboxItems(ctx context.Context, arg ListInboxItemsParams)
|
|||
|
||||
const markAllInboxRead = `-- name: MarkAllInboxRead :execrows
|
||||
UPDATE inbox_item SET read = true
|
||||
WHERE recipient_type = 'member' AND recipient_id = $1 AND archived = false AND read = false
|
||||
WHERE workspace_id = $1 AND recipient_type = 'member' AND recipient_id = $2 AND archived = false AND read = false
|
||||
`
|
||||
|
||||
func (q *Queries) MarkAllInboxRead(ctx context.Context, recipientID pgtype.UUID) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, markAllInboxRead, recipientID)
|
||||
type MarkAllInboxReadParams struct {
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
RecipientID pgtype.UUID `json:"recipient_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) MarkAllInboxRead(ctx context.Context, arg MarkAllInboxReadParams) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, markAllInboxRead, arg.WorkspaceID, arg.RecipientID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@ SELECT i.*,
|
|||
iss.status as issue_status
|
||||
FROM inbox_item i
|
||||
LEFT JOIN issue iss ON iss.id = i.issue_id
|
||||
WHERE i.recipient_type = $1 AND i.recipient_id = $2 AND i.archived = false
|
||||
WHERE i.workspace_id = $1 AND i.recipient_type = $2 AND i.recipient_id = $3 AND i.archived = false
|
||||
ORDER BY i.created_at DESC
|
||||
LIMIT $3 OFFSET $4;
|
||||
LIMIT $4 OFFSET $5;
|
||||
|
||||
-- name: GetInboxItem :one
|
||||
SELECT * FROM inbox_item
|
||||
|
|
@ -31,21 +31,21 @@ RETURNING *;
|
|||
|
||||
-- name: CountUnreadInbox :one
|
||||
SELECT count(*) FROM inbox_item
|
||||
WHERE recipient_type = $1 AND recipient_id = $2 AND read = false AND archived = false;
|
||||
WHERE workspace_id = $1 AND recipient_type = $2 AND recipient_id = $3 AND read = false AND archived = false;
|
||||
|
||||
-- name: MarkAllInboxRead :execrows
|
||||
UPDATE inbox_item SET read = true
|
||||
WHERE recipient_type = 'member' AND recipient_id = $1 AND archived = false AND read = false;
|
||||
WHERE workspace_id = $1 AND recipient_type = 'member' AND recipient_id = $2 AND archived = false AND read = false;
|
||||
|
||||
-- name: ArchiveAllInbox :execrows
|
||||
UPDATE inbox_item SET archived = true
|
||||
WHERE recipient_type = 'member' AND recipient_id = $1 AND archived = false;
|
||||
WHERE workspace_id = $1 AND recipient_type = 'member' AND recipient_id = $2 AND archived = false;
|
||||
|
||||
-- name: ArchiveAllReadInbox :execrows
|
||||
UPDATE inbox_item SET archived = true
|
||||
WHERE recipient_type = 'member' AND recipient_id = $1 AND read = true AND archived = false;
|
||||
WHERE workspace_id = $1 AND recipient_type = 'member' AND recipient_id = $2 AND read = true AND archived = false;
|
||||
|
||||
-- name: ArchiveCompletedInbox :execrows
|
||||
UPDATE inbox_item SET archived = true
|
||||
WHERE recipient_type = 'member' AND recipient_id = $1 AND archived = false
|
||||
AND issue_id IN (SELECT id FROM issue WHERE status IN ('done', 'cancelled'));
|
||||
UPDATE inbox_item i SET archived = true
|
||||
WHERE i.workspace_id = $1 AND i.recipient_type = 'member' AND i.recipient_id = $2 AND i.archived = false
|
||||
AND i.issue_id IN (SELECT id FROM issue WHERE status IN ('done', 'cancelled'));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue