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:
Naiyuan Qing 2026-03-29 17:42:45 +08:00
parent 42f72371bd
commit 4126073229
4 changed files with 75 additions and 35 deletions

View file

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

View file

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

View file

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

View file

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