mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-03 08:02:04 +00:00
Merge pull request #640 from warmbly/fix/admin-list-pagination
feat: make every admin panel list page past the first and filter by id, and report failed admin requests
This commit is contained in:
@@ -213,10 +213,10 @@ export default function AuditPage() {
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const c = data?.pagination?.cursor;
|
||||
const c = data?.pagination?.next_cursor;
|
||||
if (c) setCursors((prev) => [...prev, c]);
|
||||
}}
|
||||
disabled={!data?.pagination?.cursor}
|
||||
disabled={!data?.pagination?.next_cursor}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
|
||||
@@ -31,6 +31,10 @@ export class APIError<T = unknown> extends Error {
|
||||
// failed page can show exactly what broke instead of a generic message.
|
||||
code?: string;
|
||||
requestId?: string;
|
||||
// The call that failed, path only: no query string, so no filter value
|
||||
// reaches an error report.
|
||||
method?: string;
|
||||
path?: string;
|
||||
body?: T;
|
||||
constructor(message: string, status: number, body?: T) {
|
||||
super(message);
|
||||
@@ -148,8 +152,11 @@ export async function Request<T>(config: AuthRequestConfig): Promise<T> {
|
||||
// The path and nothing else about the call travels: no query string, no body,
|
||||
// no header.
|
||||
function noteFailure(config: AuthRequestConfig, failure: APIError): void {
|
||||
const method = config.method?.toUpperCase() ?? "REQUEST";
|
||||
// Axios sends an omitted method as GET.
|
||||
const method = config.method?.toUpperCase() ?? "GET";
|
||||
const path = config.url?.split("?")[0] ?? "";
|
||||
failure.method = method;
|
||||
failure.path = path;
|
||||
|
||||
const properties: Record<string, string | number | boolean> = {
|
||||
method,
|
||||
|
||||
@@ -135,7 +135,7 @@ export interface AdminAuditLogsResult {
|
||||
// and typing it as an array is what let `data.data.length` crash the page.
|
||||
data: AdminAuditLog[] | null;
|
||||
pagination: {
|
||||
cursor?: string;
|
||||
next_cursor?: string | null;
|
||||
has_more?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const captured = vi.hoisted(() => [] as Array<{ error: unknown; properties?: Record<string, unknown> }>);
|
||||
vi.mock("@/lib/observability", () => ({
|
||||
captureException: (error: unknown, properties?: Record<string, unknown>) => captured.push({ error, properties }),
|
||||
noteStep: () => {},
|
||||
}));
|
||||
|
||||
import { APIError, SessionExpiredError } from "@/lib/api/client";
|
||||
import { reportFailure } from "./reportFailure";
|
||||
|
||||
function apiError(status: number): APIError {
|
||||
const e = new APIError("invalid query parameters", status, { code: "bad_request", request_id: "req-1" });
|
||||
e.method = "GET";
|
||||
e.path = "/admin/users";
|
||||
return e;
|
||||
}
|
||||
|
||||
describe("reportFailure", () => {
|
||||
beforeEach(() => {
|
||||
captured.length = 0;
|
||||
});
|
||||
|
||||
it("reports a 400 on a query with the call that failed", () => {
|
||||
reportFailure(apiError(400), "query");
|
||||
expect(captured).toHaveLength(1);
|
||||
expect(captured[0].properties).toEqual({
|
||||
kind: "query",
|
||||
status: 400,
|
||||
method: "GET",
|
||||
path: "/admin/users",
|
||||
code: "bad_request",
|
||||
request_id: "req-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a 5xx mutation but not a 4xx one", () => {
|
||||
reportFailure(apiError(422), "mutation");
|
||||
reportFailure(apiError(500), "mutation");
|
||||
expect(captured.map((c) => (c.error as APIError).status)).toEqual([500]);
|
||||
});
|
||||
|
||||
it("skips expected answers, the network and a lapsed session", () => {
|
||||
for (const status of [0, 401, 403, 404, 429]) reportFailure(apiError(status), "query");
|
||||
reportFailure(new SessionExpiredError(), "query");
|
||||
expect(captured).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("reports an error thrown outside the API client", () => {
|
||||
reportFailure(new TypeError("x is undefined"), "query");
|
||||
expect(captured).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
// Reports a failed query or mutation. react-query keeps these as error state
|
||||
// for the page to render, so without this nothing reaches PostHog or Sentry.
|
||||
|
||||
import { APIError, SessionExpiredError } from "@/lib/api/client";
|
||||
import { captureException, type StepProperties } from "@/lib/observability";
|
||||
|
||||
// Answers that are the expected outcome of a request, not a defect: a signed-out
|
||||
// or under-permitted operator, a record deleted since the list loaded, a limiter.
|
||||
const EXPECTED = new Set([401, 403, 404, 429]);
|
||||
|
||||
export function shouldReport(error: unknown, kind: "query" | "mutation"): boolean {
|
||||
if (error instanceof SessionExpiredError) return false;
|
||||
if (!(error instanceof APIError)) return true;
|
||||
// Status 0 is the network, not the panel.
|
||||
if (error.status === 0 || EXPECTED.has(error.status)) return false;
|
||||
// A 4xx on a mutation is usually the operator's input, which the form shows.
|
||||
return kind === "query" || error.status >= 500;
|
||||
}
|
||||
|
||||
export function reportFailure(error: unknown, kind: "query" | "mutation"): void {
|
||||
if (!shouldReport(error, kind)) return;
|
||||
const properties: StepProperties = { kind };
|
||||
if (error instanceof APIError) {
|
||||
properties.status = error.status;
|
||||
if (error.method) properties.method = error.method;
|
||||
if (error.path) properties.path = error.path;
|
||||
if (error.code) properties.code = error.code;
|
||||
if (error.requestId) properties.request_id = error.requestId;
|
||||
}
|
||||
captureException(error, properties);
|
||||
}
|
||||
@@ -32,7 +32,7 @@ export type Identity = { userId: string; email?: string | null; name?: string |
|
||||
export type StepProperties = Record<string, string | number | boolean>;
|
||||
|
||||
type Backend = {
|
||||
capture: (error: unknown) => void;
|
||||
capture: (error: unknown, properties?: StepProperties) => void;
|
||||
identify: (identity: Identity) => void;
|
||||
step: (message: string, properties?: StepProperties) => void;
|
||||
};
|
||||
@@ -42,7 +42,7 @@ const backends: Backend[] = [];
|
||||
// EARLY_LIMIT bounds the pre-load buffers: a render loop that throws every
|
||||
// frame must not grow them without end.
|
||||
const EARLY_LIMIT = 20;
|
||||
let early: unknown[] = [];
|
||||
let early: Array<{ error: unknown; properties?: StepProperties }> = [];
|
||||
let earlySteps: Array<{ message: string; properties?: StepProperties }> = [];
|
||||
|
||||
// identity is remembered rather than forwarded once, because a backend that
|
||||
@@ -74,7 +74,7 @@ export function initErrorReporting(): void {
|
||||
.then((posthog) =>
|
||||
settle(posthog && POSTHOG_ERROR_TRACKING
|
||||
? {
|
||||
capture: (error) => void posthog.captureException(error),
|
||||
capture: (error, properties) => void posthog.captureException(error, properties),
|
||||
identify: (next) => identifyPostHog(posthog, next),
|
||||
step: (message, properties) => posthog.addExceptionStep(message, properties),
|
||||
}
|
||||
@@ -102,7 +102,8 @@ export function initErrorReporting(): void {
|
||||
release: SENTRY_RELEASE || undefined,
|
||||
});
|
||||
settle({
|
||||
capture: (error) => void Sentry.captureException(error),
|
||||
capture: (error, properties) =>
|
||||
void Sentry.captureException(error, properties ? { extra: properties } : undefined),
|
||||
identify: (next) =>
|
||||
Sentry.setUser(next
|
||||
? { id: next.userId, email: next.email ?? undefined, username: next.name ?? undefined }
|
||||
@@ -210,12 +211,12 @@ function identifyPostHog(posthog: import("posthog-js").PostHog, next: Identity):
|
||||
|
||||
// captureException reports an error the app handled itself. A no-op when no
|
||||
// backend is configured.
|
||||
export function captureException(error: unknown): void {
|
||||
export function captureException(error: unknown, properties?: StepProperties): void {
|
||||
// Remembered as well as reported while a backend is still loading, so the
|
||||
// one that has not arrived yet gets it on replay. Only the newly settled
|
||||
// backend replays, so nothing is reported twice.
|
||||
if (awaiting > 0) remember(error);
|
||||
for (const backend of backends) backend.capture(error);
|
||||
if (awaiting > 0) remember(error, properties);
|
||||
for (const backend of backends) backend.capture(error, properties);
|
||||
}
|
||||
|
||||
// setErrorIdentity names the operator later events belong to. Pass null on
|
||||
@@ -241,7 +242,7 @@ function settle(backend: Backend | null): void {
|
||||
backends.push(backend);
|
||||
if (identity) backend.identify(identity);
|
||||
for (const step of earlySteps) backend.step(step.message, step.properties);
|
||||
for (const error of early) backend.capture(error);
|
||||
for (const entry of early) backend.capture(entry.error, entry.properties);
|
||||
}
|
||||
|
||||
awaiting--;
|
||||
@@ -269,7 +270,7 @@ function installEarlyHandlers(): void {
|
||||
};
|
||||
}
|
||||
|
||||
function remember(error: unknown): void {
|
||||
function remember(error: unknown, properties?: StepProperties): void {
|
||||
if (early.length >= EARLY_LIMIT) return;
|
||||
early.push(error);
|
||||
early.push({ error, properties });
|
||||
}
|
||||
|
||||
+4
-1
@@ -8,13 +8,14 @@ import {
|
||||
RouterProvider,
|
||||
useLocation,
|
||||
} from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MutationCache, QueryCache, QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
|
||||
import "@fontsource/inter/400.css";
|
||||
import "@fontsource/inter/600.css";
|
||||
|
||||
import { initErrorReporting } from "@/lib/observability";
|
||||
import { reportFailure } from "@/lib/api/reportFailure";
|
||||
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { AppShell } from "@/components/layout/AppShell";
|
||||
@@ -65,6 +66,8 @@ import { AdminPerm } from "@/lib/auth/permissions";
|
||||
// day; we don't want a thundering herd of refetches on focus
|
||||
// - retry: 1 — same reasoning as the dashboard
|
||||
const queryClient = new QueryClient({
|
||||
queryCache: new QueryCache({ onError: (error) => reportFailure(error, "query") }),
|
||||
mutationCache: new MutationCache({ onError: (error) => reportFailure(error, "mutation") }),
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60_000,
|
||||
|
||||
@@ -96,7 +96,7 @@ func collectStatus(ctx context.Context, c *conn) (*instanceStatus, error) {
|
||||
}
|
||||
|
||||
admins := []string{}
|
||||
result, aerr := c.admins.ListAdmins(ctx, nil, 100)
|
||||
result, aerr := c.admins.ListAdmins(ctx, 0, 100)
|
||||
if aerr != nil {
|
||||
return nil, fmt.Errorf("listing platform admins: %w", aerr)
|
||||
}
|
||||
|
||||
@@ -427,7 +427,7 @@ func runUserRevokeAdmin(ctx context.Context, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
result, lerr := c.admins.ListAdmins(ctx, nil, 100)
|
||||
result, lerr := c.admins.ListAdmins(ctx, 0, 100)
|
||||
if lerr != nil {
|
||||
return fmt.Errorf("counting the remaining platform admins: %w", lerr)
|
||||
}
|
||||
|
||||
@@ -23,6 +23,11 @@ func (h *Handler) AdminSearchUsers(c *gin.Context) {
|
||||
errx.JSON(c, errx.New(errx.BadRequest, "invalid query parameters"))
|
||||
return
|
||||
}
|
||||
offset, ok := offsetCursor(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
search.Offset = offset
|
||||
|
||||
result, xerr := h.AdminService.SearchUsers(c.Request.Context(), &search)
|
||||
if xerr != nil {
|
||||
@@ -150,10 +155,13 @@ func (h *Handler) AdminGetUserCampaigns(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
cursor := parseCursor(c.Query("cursor"))
|
||||
offset, ok := offsetCursor(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
limit := parseLimit(c.Query("limit"), 50)
|
||||
|
||||
result, xerr := h.AdminService.GetUserCampaigns(c.Request.Context(), userID, cursor, limit)
|
||||
result, xerr := h.AdminService.GetUserCampaigns(c.Request.Context(), userID, offset, limit)
|
||||
if xerr != nil {
|
||||
errx.JSON(c, xerr)
|
||||
return
|
||||
@@ -170,10 +178,13 @@ func (h *Handler) AdminGetUserEmails(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
cursor := parseCursor(c.Query("cursor"))
|
||||
offset, ok := offsetCursor(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
limit := parseLimit(c.Query("limit"), 50)
|
||||
|
||||
emails, pagination, xerr := h.AdminService.GetUserEmails(c.Request.Context(), userID, cursor, limit)
|
||||
emails, pagination, xerr := h.AdminService.GetUserEmails(c.Request.Context(), userID, offset, limit)
|
||||
if xerr != nil {
|
||||
errx.JSON(c, xerr)
|
||||
return
|
||||
@@ -232,10 +243,13 @@ func (h *Handler) AdminUpdateUserRateLimits(c *gin.Context) {
|
||||
|
||||
// AdminListWorkers lists all workers
|
||||
func (h *Handler) AdminListWorkers(c *gin.Context) {
|
||||
cursor := parseCursor(c.Query("cursor"))
|
||||
offset, ok := offsetCursor(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
limit := parseLimit(c.Query("limit"), 50)
|
||||
|
||||
result, xerr := h.AdminService.ListWorkers(c.Request.Context(), cursor, limit)
|
||||
result, xerr := h.AdminService.ListWorkers(c.Request.Context(), offset, limit)
|
||||
if xerr != nil {
|
||||
errx.JSON(c, xerr)
|
||||
return
|
||||
@@ -391,10 +405,13 @@ func (h *Handler) AdminGetWarmupHealthSummary(c *gin.Context) {
|
||||
// AdminGetPoolParticipants gets participants in a warmup pool
|
||||
func (h *Handler) AdminGetPoolParticipants(c *gin.Context) {
|
||||
poolType := c.Param("type")
|
||||
cursor := parseCursor(c.Query("cursor"))
|
||||
offset, ok := offsetCursor(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
limit := parseLimit(c.Query("limit"), 50)
|
||||
|
||||
result, xerr := h.AdminService.GetPoolParticipants(c.Request.Context(), poolType, cursor, limit)
|
||||
result, xerr := h.AdminService.GetPoolParticipants(c.Request.Context(), poolType, offset, limit)
|
||||
if xerr != nil {
|
||||
errx.JSON(c, xerr)
|
||||
return
|
||||
@@ -405,10 +422,13 @@ func (h *Handler) AdminGetPoolParticipants(c *gin.Context) {
|
||||
|
||||
// AdminListBlockedAccounts lists blocked warmup accounts
|
||||
func (h *Handler) AdminListBlockedAccounts(c *gin.Context) {
|
||||
cursor := parseCursor(c.Query("cursor"))
|
||||
offset, ok := offsetCursor(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
limit := parseLimit(c.Query("limit"), 50)
|
||||
|
||||
result, xerr := h.AdminService.ListBlockedAccounts(c.Request.Context(), cursor, limit)
|
||||
result, xerr := h.AdminService.ListBlockedAccounts(c.Request.Context(), offset, limit)
|
||||
if xerr != nil {
|
||||
errx.JSON(c, xerr)
|
||||
return
|
||||
@@ -474,10 +494,13 @@ func (h *Handler) AdminUnblockAccount(c *gin.Context) {
|
||||
// AdminListAppeals lists warmup appeals
|
||||
func (h *Handler) AdminListAppeals(c *gin.Context) {
|
||||
status := c.Query("status")
|
||||
cursor := parseCursor(c.Query("cursor"))
|
||||
offset, ok := offsetCursor(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
limit := parseLimit(c.Query("limit"), 50)
|
||||
|
||||
result, xerr := h.AdminService.ListAppeals(c.Request.Context(), status, cursor, limit)
|
||||
result, xerr := h.AdminService.ListAppeals(c.Request.Context(), status, offset, limit)
|
||||
if xerr != nil {
|
||||
errx.JSON(c, xerr)
|
||||
return
|
||||
@@ -570,6 +593,11 @@ func (h *Handler) AdminSearchCampaigns(c *gin.Context) {
|
||||
errx.JSON(c, errx.New(errx.BadRequest, "invalid query parameters"))
|
||||
return
|
||||
}
|
||||
offset, ok := offsetCursor(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
search.Offset = offset
|
||||
|
||||
result, xerr := h.AdminService.SearchCampaigns(c.Request.Context(), &search)
|
||||
if xerr != nil {
|
||||
@@ -704,10 +732,13 @@ func (h *Handler) AdminGetUserGrowthStats(c *gin.Context) {
|
||||
|
||||
// AdminListAdmins lists all admin users
|
||||
func (h *Handler) AdminListAdmins(c *gin.Context) {
|
||||
cursor := parseCursor(c.Query("cursor"))
|
||||
offset, ok := offsetCursor(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
limit := parseLimit(c.Query("limit"), 50)
|
||||
|
||||
result, xerr := h.AdminService.ListAdmins(c.Request.Context(), cursor, limit)
|
||||
result, xerr := h.AdminService.ListAdmins(c.Request.Context(), offset, limit)
|
||||
if xerr != nil {
|
||||
errx.JSON(c, xerr)
|
||||
return
|
||||
@@ -775,6 +806,10 @@ func (h *Handler) AdminSearchAuditLogs(c *gin.Context) {
|
||||
errx.JSON(c, errx.New(errx.BadRequest, "invalid query parameters"))
|
||||
return
|
||||
}
|
||||
if _, _, xerr := paging.DecodeTimeCursor(search.Cursor); xerr != nil {
|
||||
errx.JSON(c, xerr)
|
||||
return
|
||||
}
|
||||
|
||||
result, xerr := h.AdminService.SearchAuditLogs(c.Request.Context(), &search)
|
||||
if xerr != nil {
|
||||
@@ -793,6 +828,17 @@ func (h *Handler) AdminGetPermissionList(c *gin.Context) {
|
||||
|
||||
// Helper functions
|
||||
|
||||
// offsetCursor decodes an admin list's opaque ?cursor, answering 400 for a
|
||||
// malformed one rather than quietly serving the first page.
|
||||
func offsetCursor(c *gin.Context) (int, bool) {
|
||||
offset, xerr := paging.DecodeOffsetCursor(c.Query("cursor"))
|
||||
if xerr != nil {
|
||||
errx.JSON(c, xerr)
|
||||
return 0, false
|
||||
}
|
||||
return offset, true
|
||||
}
|
||||
|
||||
func parseCursor(s string) *uuid.UUID {
|
||||
if s == "" {
|
||||
return nil
|
||||
|
||||
@@ -17,6 +17,11 @@ func (h *Handler) AdminSearchMailboxes(c *gin.Context) {
|
||||
errx.JSON(c, errx.New(errx.BadRequest, "invalid query parameters"))
|
||||
return
|
||||
}
|
||||
offset, ok := offsetCursor(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
search.Offset = offset
|
||||
result, xerr := h.AdminService.SearchMailboxes(c.Request.Context(), &search)
|
||||
if xerr != nil {
|
||||
errx.JSON(c, xerr)
|
||||
|
||||
@@ -17,6 +17,11 @@ func (h *Handler) AdminListOrganizations(c *gin.Context) {
|
||||
errx.JSON(c, errx.New(errx.BadRequest, "invalid query parameters"))
|
||||
return
|
||||
}
|
||||
offset, ok := offsetCursor(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
search.Offset = offset
|
||||
|
||||
result, xerr := h.OrganizationService.SearchOrganizationsForAdmin(c.Request.Context(), &search)
|
||||
if xerr != nil {
|
||||
|
||||
@@ -64,6 +64,11 @@ func (h *Handler) AdminListOutreach(c *gin.Context) {
|
||||
errx.JSON(c, errx.New(errx.BadRequest, "invalid query parameters"))
|
||||
return
|
||||
}
|
||||
offset, ok := offsetCursor(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
search.Offset = offset
|
||||
result, xerr := h.AdminOutreachService.Search(c.Request.Context(), &search)
|
||||
if xerr != nil {
|
||||
errx.JSON(c, xerr)
|
||||
|
||||
@@ -86,6 +86,11 @@ func (h *Handler) AdminListLimitRequests(c *gin.Context) {
|
||||
errx.JSON(c, errx.New(errx.BadRequest, "invalid query parameters"))
|
||||
return
|
||||
}
|
||||
offset, ok := offsetCursor(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
search.Offset = offset
|
||||
result, xerr := h.OrganizationService.AdminListLimitRequests(c.Request.Context(), &search)
|
||||
if xerr != nil {
|
||||
errx.JSON(c, xerr)
|
||||
|
||||
@@ -23,13 +23,13 @@ type AdminService interface {
|
||||
BanUser(ctx context.Context, adminID, userID uuid.UUID, reason string, scope models.BanScope, ipAddress, userAgent string) *errx.Error
|
||||
UnbanUser(ctx context.Context, adminID, userID uuid.UUID, reason string, ipAddress, userAgent string) *errx.Error
|
||||
GetUserBans(ctx context.Context, userID uuid.UUID) ([]models.UserBan, *errx.Error)
|
||||
GetUserCampaigns(ctx context.Context, userID uuid.UUID, cursor *uuid.UUID, limit int) (*models.AdminCampaignsResult, *errx.Error)
|
||||
GetUserEmails(ctx context.Context, userID uuid.UUID, cursor *uuid.UUID, limit int) ([]models.AdminWorkerEmail, *models.Pagination, *errx.Error)
|
||||
GetUserCampaigns(ctx context.Context, userID uuid.UUID, offset, limit int) (*models.AdminCampaignsResult, *errx.Error)
|
||||
GetUserEmails(ctx context.Context, userID uuid.UUID, offset, limit int) ([]models.AdminWorkerEmail, *models.Pagination, *errx.Error)
|
||||
GetUserRateLimits(ctx context.Context, userID uuid.UUID) (*models.AdminUserRateLimits, *errx.Error)
|
||||
UpdateUserRateLimits(ctx context.Context, adminID, userID uuid.UUID, update *models.UpdateUserRateLimitsRequest, ipAddress, userAgent string) (*models.AdminUserRateLimits, *errx.Error)
|
||||
|
||||
// Worker Management
|
||||
ListWorkers(ctx context.Context, cursor *uuid.UUID, limit int) (*models.AdminWorkersResult, *errx.Error)
|
||||
ListWorkers(ctx context.Context, offset, limit int) (*models.AdminWorkersResult, *errx.Error)
|
||||
GetWorkerDetail(ctx context.Context, workerID uuid.UUID) (*models.AdminWorkerDetail, *errx.Error)
|
||||
UpdateWorker(ctx context.Context, adminID, workerID uuid.UUID, update *models.AdminUpdateWorker, ipAddress, userAgent string) *errx.Error
|
||||
GetWorkerEmails(ctx context.Context, workerID uuid.UUID, beforeAt time.Time, beforeID uuid.UUID, limit int) ([]models.AdminWorkerEmail, *models.Pagination, *errx.Error)
|
||||
@@ -38,13 +38,13 @@ type AdminService interface {
|
||||
|
||||
// Warmup Management
|
||||
ListWarmupPools(ctx context.Context) ([]models.WarmupPoolInfo, *errx.Error)
|
||||
GetPoolParticipants(ctx context.Context, poolType string, cursor *uuid.UUID, limit int) (*models.WarmupPoolParticipantsResult, *errx.Error)
|
||||
ListBlockedAccounts(ctx context.Context, cursor *uuid.UUID, limit int) (*models.AdminBlockedAccountsResult, *errx.Error)
|
||||
GetPoolParticipants(ctx context.Context, poolType string, offset, limit int) (*models.WarmupPoolParticipantsResult, *errx.Error)
|
||||
ListBlockedAccounts(ctx context.Context, offset, limit int) (*models.AdminBlockedAccountsResult, *errx.Error)
|
||||
BlockAccount(ctx context.Context, adminID, accountID uuid.UUID, reason string, ipAddress, userAgent string) *errx.Error
|
||||
UnblockAccount(ctx context.Context, adminID, accountID uuid.UUID, ipAddress, userAgent string) *errx.Error
|
||||
|
||||
// Appeals
|
||||
ListAppeals(ctx context.Context, status string, cursor *uuid.UUID, limit int) (*models.WarmupAppealsResult, *errx.Error)
|
||||
ListAppeals(ctx context.Context, status string, offset, limit int) (*models.WarmupAppealsResult, *errx.Error)
|
||||
GetAppeal(ctx context.Context, appealID uuid.UUID) (*models.WarmupAppeal, *errx.Error)
|
||||
ReviewAppeal(ctx context.Context, adminID, appealID uuid.UUID, approved bool, notes string, ipAddress, userAgent string) *errx.Error
|
||||
|
||||
@@ -61,7 +61,7 @@ type AdminService interface {
|
||||
GetUserGrowthStats(ctx context.Context, startDate, endDate time.Time) ([]models.UserGrowthStats, *errx.Error)
|
||||
|
||||
// Admin Management
|
||||
ListAdmins(ctx context.Context, cursor *uuid.UUID, limit int) (*models.AdminsResult, *errx.Error)
|
||||
ListAdmins(ctx context.Context, offset, limit int) (*models.AdminsResult, *errx.Error)
|
||||
GrantAdminPermissions(ctx context.Context, adminID, targetUserID uuid.UUID, permissions models.AdminPermission, ipAddress, userAgent string) *errx.Error
|
||||
RevokeAdminPermissions(ctx context.Context, adminID, targetUserID uuid.UUID, ipAddress, userAgent string) *errx.Error
|
||||
|
||||
@@ -262,10 +262,10 @@ func (s *adminService) GetUserBans(ctx context.Context, userID uuid.UUID) ([]mod
|
||||
return bans, nil
|
||||
}
|
||||
|
||||
func (s *adminService) GetUserCampaigns(ctx context.Context, userID uuid.UUID, cursor *uuid.UUID, limit int) (*models.AdminCampaignsResult, *errx.Error) {
|
||||
func (s *adminService) GetUserCampaigns(ctx context.Context, userID uuid.UUID, offset, limit int) (*models.AdminCampaignsResult, *errx.Error) {
|
||||
search := &models.AdminCampaignSearch{
|
||||
UserID: &userID,
|
||||
Cursor: cursor,
|
||||
UserID: &models.ParamUUID{UUID: userID},
|
||||
Offset: offset,
|
||||
Limit: limit,
|
||||
}
|
||||
result, err := s.repo.SearchCampaigns(ctx, search)
|
||||
@@ -276,8 +276,8 @@ func (s *adminService) GetUserCampaigns(ctx context.Context, userID uuid.UUID, c
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *adminService) GetUserEmails(ctx context.Context, userID uuid.UUID, cursor *uuid.UUID, limit int) ([]models.AdminWorkerEmail, *models.Pagination, *errx.Error) {
|
||||
emails, pagination, err := s.repo.GetUserEmails(ctx, userID, cursor, limit)
|
||||
func (s *adminService) GetUserEmails(ctx context.Context, userID uuid.UUID, offset, limit int) ([]models.AdminWorkerEmail, *models.Pagination, *errx.Error) {
|
||||
emails, pagination, err := s.repo.GetUserEmails(ctx, userID, offset, limit)
|
||||
if err != nil {
|
||||
errs.CaptureException(err)
|
||||
return nil, nil, errx.New(errx.Internal, "failed to get user emails")
|
||||
@@ -310,8 +310,8 @@ func (s *adminService) UpdateUserRateLimits(ctx context.Context, adminID, userID
|
||||
|
||||
// Worker Management
|
||||
|
||||
func (s *adminService) ListWorkers(ctx context.Context, cursor *uuid.UUID, limit int) (*models.AdminWorkersResult, *errx.Error) {
|
||||
result, err := s.repo.ListWorkers(ctx, cursor, limit)
|
||||
func (s *adminService) ListWorkers(ctx context.Context, offset, limit int) (*models.AdminWorkersResult, *errx.Error) {
|
||||
result, err := s.repo.ListWorkers(ctx, offset, limit)
|
||||
if err != nil {
|
||||
errs.CaptureException(err)
|
||||
return nil, errx.New(errx.Internal, "failed to list workers")
|
||||
@@ -380,8 +380,8 @@ func (s *adminService) ListWarmupPools(ctx context.Context) ([]models.WarmupPool
|
||||
return pools, nil
|
||||
}
|
||||
|
||||
func (s *adminService) GetPoolParticipants(ctx context.Context, poolType string, cursor *uuid.UUID, limit int) (*models.WarmupPoolParticipantsResult, *errx.Error) {
|
||||
result, err := s.repo.GetPoolParticipants(ctx, poolType, cursor, limit)
|
||||
func (s *adminService) GetPoolParticipants(ctx context.Context, poolType string, offset, limit int) (*models.WarmupPoolParticipantsResult, *errx.Error) {
|
||||
result, err := s.repo.GetPoolParticipants(ctx, poolType, offset, limit)
|
||||
if err != nil {
|
||||
errs.CaptureException(err)
|
||||
return nil, errx.New(errx.Internal, "failed to get pool participants")
|
||||
@@ -389,8 +389,8 @@ func (s *adminService) GetPoolParticipants(ctx context.Context, poolType string,
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *adminService) ListBlockedAccounts(ctx context.Context, cursor *uuid.UUID, limit int) (*models.AdminBlockedAccountsResult, *errx.Error) {
|
||||
result, err := s.repo.ListBlockedAccounts(ctx, cursor, limit)
|
||||
func (s *adminService) ListBlockedAccounts(ctx context.Context, offset, limit int) (*models.AdminBlockedAccountsResult, *errx.Error) {
|
||||
result, err := s.repo.ListBlockedAccounts(ctx, offset, limit)
|
||||
if err != nil {
|
||||
errs.CaptureException(err)
|
||||
return nil, errx.New(errx.Internal, "failed to list blocked accounts")
|
||||
@@ -420,8 +420,8 @@ func (s *adminService) UnblockAccount(ctx context.Context, adminID, accountID uu
|
||||
|
||||
// Appeals
|
||||
|
||||
func (s *adminService) ListAppeals(ctx context.Context, status string, cursor *uuid.UUID, limit int) (*models.WarmupAppealsResult, *errx.Error) {
|
||||
result, err := s.repo.ListAppeals(ctx, status, cursor, limit)
|
||||
func (s *adminService) ListAppeals(ctx context.Context, status string, offset, limit int) (*models.WarmupAppealsResult, *errx.Error) {
|
||||
result, err := s.repo.ListAppeals(ctx, status, offset, limit)
|
||||
if err != nil {
|
||||
errs.CaptureException(err)
|
||||
return nil, errx.New(errx.Internal, "failed to list appeals")
|
||||
@@ -580,8 +580,8 @@ func (s *adminService) GetUserGrowthStats(ctx context.Context, startDate, endDat
|
||||
|
||||
// Admin Management
|
||||
|
||||
func (s *adminService) ListAdmins(ctx context.Context, cursor *uuid.UUID, limit int) (*models.AdminsResult, *errx.Error) {
|
||||
result, err := s.repo.ListAdmins(ctx, cursor, limit)
|
||||
func (s *adminService) ListAdmins(ctx context.Context, offset, limit int) (*models.AdminsResult, *errx.Error) {
|
||||
result, err := s.repo.ListAdmins(ctx, offset, limit)
|
||||
if err != nil {
|
||||
errs.CaptureException(err)
|
||||
return nil, errx.New(errx.Internal, "failed to list admins")
|
||||
|
||||
+34
-34
@@ -16,7 +16,7 @@ type AdminUserSearch struct {
|
||||
CreatedWithin int `form:"created_within"` // signup window, in days
|
||||
|
||||
// Plan / subscription
|
||||
PlanID *uuid.UUID `form:"plan_id"` // subscribed to THIS plan
|
||||
PlanID *ParamUUID `form:"plan_id"` // subscribed to THIS plan
|
||||
SubscriptionStatus string `form:"subscription_status"` // trialing, active, past_due, ...
|
||||
IsEnterprise bool `form:"is_enterprise"`
|
||||
HasSubscription bool `form:"has_subscription"`
|
||||
@@ -50,10 +50,10 @@ type AdminUserSearch struct {
|
||||
UpdatedAfter *time.Time `form:"updated_after" time_format:"2006-01-02" time_utc:"true"`
|
||||
UpdatedBefore *time.Time `form:"updated_before" time_format:"2006-01-02" time_utc:"true"`
|
||||
|
||||
Cursor *uuid.UUID `form:"cursor"`
|
||||
Limit int `form:"limit"`
|
||||
SortBy string `form:"sort_by"` // created_at, email, name
|
||||
SortDesc bool `form:"sort_desc"`
|
||||
Offset int `form:"-"` // decoded from ?cursor by the handler
|
||||
Limit int `form:"limit"`
|
||||
SortBy string `form:"sort_by"` // created_at, email, name
|
||||
SortDesc bool `form:"sort_desc"`
|
||||
}
|
||||
|
||||
// AdminUserDetail represents a user with admin-relevant statistics
|
||||
@@ -308,8 +308,8 @@ type AdminCampaignsResult struct {
|
||||
// *time.Time with time_format/time_utc, mirroring AdminOrgSearch.
|
||||
type AdminCampaignSearch struct {
|
||||
Query string `form:"q"`
|
||||
UserID *uuid.UUID `form:"user_id"`
|
||||
OrgID *uuid.UUID `form:"org_id"`
|
||||
UserID *ParamUUID `form:"user_id"`
|
||||
OrgID *ParamUUID `form:"org_id"`
|
||||
Status string `form:"status"` // draft, active, paused, completed, paused_trial_expired, paused_no_accounts, paused_guardrail, paused_undeliverable
|
||||
|
||||
// Boolean flags
|
||||
@@ -340,10 +340,10 @@ type AdminCampaignSearch struct {
|
||||
UpdatedAfter *time.Time `form:"updated_after" time_format:"2006-01-02" time_utc:"true"`
|
||||
UpdatedBefore *time.Time `form:"updated_before" time_format:"2006-01-02" time_utc:"true"`
|
||||
|
||||
Cursor *uuid.UUID `form:"cursor"`
|
||||
Limit int `form:"limit"`
|
||||
SortBy string `form:"sort_by"`
|
||||
SortDesc bool `form:"sort_desc"`
|
||||
Offset int `form:"-"` // decoded from ?cursor by the handler
|
||||
Limit int `form:"limit"`
|
||||
SortBy string `form:"sort_by"`
|
||||
SortDesc bool `form:"sort_desc"`
|
||||
}
|
||||
|
||||
// AdminAuditLog represents an audit log entry for admin actions
|
||||
@@ -370,13 +370,13 @@ type AdminAuditLogsResult struct {
|
||||
|
||||
// AdminAuditLogSearch represents search parameters for audit logs
|
||||
type AdminAuditLogSearch struct {
|
||||
AdminUserID *uuid.UUID `form:"admin_user_id"`
|
||||
AdminUserID *ParamUUID `form:"admin_user_id"`
|
||||
Action string `form:"action"`
|
||||
TargetType string `form:"target_type"`
|
||||
TargetID *uuid.UUID `form:"target_id"`
|
||||
StartDate *time.Time `form:"start_date"`
|
||||
EndDate *time.Time `form:"end_date"`
|
||||
Cursor *uuid.UUID `form:"cursor"`
|
||||
TargetID *ParamUUID `form:"target_id"`
|
||||
StartDate *time.Time `form:"start_date" time_format:"2006-01-02" time_utc:"true"`
|
||||
EndDate *time.Time `form:"end_date" time_format:"2006-01-02" time_utc:"true"`
|
||||
Cursor string `form:"cursor"`
|
||||
Limit int `form:"limit"`
|
||||
}
|
||||
|
||||
@@ -440,11 +440,11 @@ type AdminMailboxSearch struct {
|
||||
Provider string `form:"provider"`
|
||||
Warmup string `form:"warmup"` // "on", "off", or "" for any
|
||||
CreatedWithin int `form:"created_within"` // connected window, in days
|
||||
OrgID *uuid.UUID `form:"org_id"` // browse a single org's mailboxes
|
||||
OrgID *ParamUUID `form:"org_id"` // browse a single org's mailboxes
|
||||
|
||||
// Ownership / placement
|
||||
UserID *uuid.UUID `form:"user_id"` // mailboxes owned by THIS user
|
||||
WorkerID *uuid.UUID `form:"worker_id"` // mailboxes on THIS worker
|
||||
UserID *ParamUUID `form:"user_id"` // mailboxes owned by THIS user
|
||||
WorkerID *ParamUUID `form:"worker_id"` // mailboxes on THIS worker
|
||||
|
||||
// Classification
|
||||
RiskBand string `form:"risk_band"` // clean, risky, quarantine
|
||||
@@ -472,10 +472,10 @@ type AdminMailboxSearch struct {
|
||||
LastSyncedAfter *time.Time `form:"last_synced_after" time_format:"2006-01-02" time_utc:"true"`
|
||||
LastSyncedBefore *time.Time `form:"last_synced_before" time_format:"2006-01-02" time_utc:"true"`
|
||||
|
||||
Cursor *uuid.UUID `form:"cursor"`
|
||||
Limit int `form:"limit"`
|
||||
SortBy string `form:"sort_by"` // email, created_at, last_synced_at, campaign_limit
|
||||
SortDesc bool `form:"sort_desc"`
|
||||
Offset int `form:"-"` // decoded from ?cursor by the handler
|
||||
Limit int `form:"limit"`
|
||||
SortBy string `form:"sort_by"` // email, created_at, last_synced_at, campaign_limit
|
||||
SortDesc bool `form:"sort_desc"`
|
||||
}
|
||||
|
||||
// DailyEmailStats represents daily email statistics for graphs
|
||||
@@ -654,7 +654,7 @@ type WorkerStats struct {
|
||||
type AdminOrgSearch struct {
|
||||
Query string `form:"q"`
|
||||
Status string `form:"status"` // active, pending_deletion, all
|
||||
PlanID *uuid.UUID `form:"plan_id"`
|
||||
PlanID *ParamUUID `form:"plan_id"`
|
||||
PlanVisibility string `form:"plan_visibility"` // public, private, none
|
||||
CreatedWithin int `form:"created_within"` // days; 0 = any
|
||||
HasOverrides bool `form:"has_overrides"` // has organization_limit_overrides
|
||||
@@ -707,10 +707,10 @@ type AdminOrgSearch struct {
|
||||
HasAcquisition bool `form:"has_acquisition"`
|
||||
NoAcquisition bool `form:"no_acquisition"`
|
||||
|
||||
Cursor *uuid.UUID `form:"cursor"`
|
||||
Limit int `form:"limit"`
|
||||
SortBy string `form:"sort_by"` // created_at, name, owner_email, member_count, campaign_count, email_account_count
|
||||
SortDesc bool `form:"sort_desc"`
|
||||
Offset int `form:"-"` // decoded from ?cursor by the handler
|
||||
Limit int `form:"limit"`
|
||||
SortBy string `form:"sort_by"` // created_at, name, owner_email, member_count, campaign_count, email_account_count
|
||||
SortDesc bool `form:"sort_desc"`
|
||||
}
|
||||
|
||||
// AdminOrgListItem is one row in the admin org list. It carries enough
|
||||
@@ -778,8 +778,8 @@ type AdminLimitRequestSearch struct {
|
||||
Status string `form:"status"` // pending, approved, rejected, cancelled, all
|
||||
Field string `form:"field"` // app-validated: max_email_accounts ... daily_campaign_limit
|
||||
|
||||
OrgID *uuid.UUID `form:"org_id"`
|
||||
SubmittedBy *uuid.UUID `form:"submitted_by"`
|
||||
OrgID *ParamUUID `form:"org_id"`
|
||||
SubmittedBy *ParamUUID `form:"submitted_by"`
|
||||
|
||||
// Flags
|
||||
Reviewed bool `form:"reviewed"` // reviewed_at IS NOT NULL
|
||||
@@ -798,10 +798,10 @@ type AdminLimitRequestSearch struct {
|
||||
ReviewedAfter *time.Time `form:"reviewed_after" time_format:"2006-01-02" time_utc:"true"`
|
||||
ReviewedBefore *time.Time `form:"reviewed_before" time_format:"2006-01-02" time_utc:"true"`
|
||||
|
||||
Cursor *uuid.UUID `form:"cursor"`
|
||||
Limit int `form:"limit"`
|
||||
SortBy string `form:"sort_by"` // submitted_at, requested, current_effective, reviewed_at, status, field, org_name
|
||||
SortDesc bool `form:"sort_desc"`
|
||||
Offset int `form:"-"` // decoded from ?cursor by the handler
|
||||
Limit int `form:"limit"`
|
||||
SortBy string `form:"sort_by"` // submitted_at, requested, current_effective, reviewed_at, status, field, org_name
|
||||
SortDesc bool `form:"sort_desc"`
|
||||
}
|
||||
|
||||
// AdminLimitRequestsResult is the paginated admin limit-request response.
|
||||
|
||||
@@ -70,10 +70,10 @@ type AdminOutreachSearch struct {
|
||||
SentAtAfter *time.Time `form:"sent_at_after" time_format:"2006-01-02" time_utc:"true"`
|
||||
SentAtBefore *time.Time `form:"sent_at_before" time_format:"2006-01-02" time_utc:"true"`
|
||||
|
||||
Cursor *uuid.UUID `form:"cursor"`
|
||||
Limit int `form:"limit"`
|
||||
SortBy string `form:"sort_by"` // created_at, sent_at, status, to_email, subject
|
||||
SortDesc bool `form:"sort_desc"`
|
||||
Offset int `form:"-"` // decoded from ?cursor by the handler
|
||||
Limit int `form:"limit"`
|
||||
SortBy string `form:"sort_by"` // created_at, sent_at, status, to_email, subject
|
||||
SortDesc bool `form:"sort_desc"`
|
||||
}
|
||||
|
||||
// AdminOutreachResult is the paginated response for the admin outreach log.
|
||||
|
||||
@@ -159,7 +159,7 @@ type AdminDiscountSearch struct {
|
||||
|
||||
// Plan eligibility
|
||||
PlanScope string `form:"plan_scope"` // all|specific
|
||||
PlanID *uuid.UUID `form:"plan_id"`
|
||||
PlanID *ParamUUID `form:"plan_id"`
|
||||
|
||||
// Flag / relationship existence
|
||||
HasRedemptions bool `form:"has_redemptions"`
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package models
|
||||
|
||||
import "github.com/google/uuid"
|
||||
|
||||
// ParamUUID is a uuid bound from a query string; gin's form binding cannot set a
|
||||
// bare uuid.UUID and rejects the whole request instead.
|
||||
type ParamUUID struct {
|
||||
uuid.UUID
|
||||
}
|
||||
|
||||
// UnmarshalParam implements gin's binding.BindUnmarshaler.
|
||||
func (p *ParamUUID) UnmarshalParam(param string) error {
|
||||
id, err := uuid.Parse(param)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.UUID = id
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func bindQuery(t *testing.T, rawQuery string, dst any) error {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest("GET", "/?"+rawQuery, nil)
|
||||
return c.ShouldBindQuery(dst)
|
||||
}
|
||||
|
||||
func TestParamUUIDBindsFromQuery(t *testing.T) {
|
||||
id := uuid.New()
|
||||
var s AdminMailboxSearch
|
||||
if err := bindQuery(t, "worker_id="+id.String()+"&org_id="+id.String(), &s); err != nil {
|
||||
t.Fatalf("bind: %v", err)
|
||||
}
|
||||
if s.WorkerID == nil || s.WorkerID.UUID != id || s.OrgID == nil || s.OrgID.UUID != id {
|
||||
t.Fatalf("got worker %v org %v, want %s", s.WorkerID, s.OrgID, id)
|
||||
}
|
||||
if s.UserID != nil {
|
||||
t.Fatalf("absent user_id bound to %v", s.UserID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParamUUIDRejectsGarbage(t *testing.T) {
|
||||
var s AdminMailboxSearch
|
||||
if err := bindQuery(t, "worker_id=not-a-uuid", &s); err == nil {
|
||||
t.Fatal("bound a malformed uuid")
|
||||
}
|
||||
}
|
||||
|
||||
// Every admin search struct must bind its ids and cursor together, so a field
|
||||
// typed as a bare uuid.UUID cannot 400 an explorer again.
|
||||
func TestAdminSearchStructsBindIDsAndCursor(t *testing.T) {
|
||||
id := uuid.NewString()
|
||||
for _, dst := range []any{
|
||||
&AdminUserSearch{}, &AdminCampaignSearch{}, &AdminAuditLogSearch{}, &AdminMailboxSearch{},
|
||||
&AdminOrgSearch{}, &AdminLimitRequestSearch{}, &AdminOutreachSearch{}, &AdminDiscountSearch{},
|
||||
} {
|
||||
typ := reflect.TypeOf(dst).Elem()
|
||||
query := ""
|
||||
for i := 0; i < typ.NumField(); i++ {
|
||||
f := typ.Field(i)
|
||||
key := f.Tag.Get("form")
|
||||
ft := f.Type
|
||||
if ft.Kind() == reflect.Pointer {
|
||||
ft = ft.Elem()
|
||||
}
|
||||
if ft == reflect.TypeOf(uuid.UUID{}) {
|
||||
t.Errorf("%s.%s is a uuid.UUID, which gin cannot bind; use ParamUUID", typ.Name(), f.Name)
|
||||
}
|
||||
if ft == reflect.TypeOf(ParamUUID{}) {
|
||||
query += key + "=" + id + "&"
|
||||
}
|
||||
}
|
||||
query += "cursor=o1_NTA"
|
||||
if err := bindQuery(t, query, dst); err != nil {
|
||||
t.Errorf("%s: bind %q: %v", typ.Name(), query, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/warmbly/warmbly/internal/utils/paging"
|
||||
)
|
||||
|
||||
// The admin lists page by offset: their sorts are operator-chosen and often
|
||||
// nullable, so no single keyset fits them.
|
||||
|
||||
// adminLimitOffset is the tail of a paged admin query. limitParam is the
|
||||
// placeholder carrying limit+1; the offset is an int, so it is rendered inline
|
||||
// and the count queries can keep dropping only the trailing LIMIT arg.
|
||||
func adminLimitOffset(limitParam string, offset int) string {
|
||||
return "LIMIT " + limitParam + " OFFSET " + strconv.Itoa(offset)
|
||||
}
|
||||
|
||||
// adminNextCursor is the cursor for the page after one that started at offset.
|
||||
func adminNextCursor(offset, limit int) *string {
|
||||
return paging.EncodeOffset(offset + limit)
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
"github.com/warmbly/warmbly/internal/utils/paging"
|
||||
)
|
||||
|
||||
// Issue #639: the admin lists paged on id < cursor while ordering by another
|
||||
// column, so with random ids page two was an arbitrary slice. Each list is
|
||||
// walked one row at a time here and must return every row exactly once.
|
||||
//
|
||||
// WARMBLY_TEST_DB=postgres://warmbly:warmbly@localhost:15432/<db>?sslmode=disable \
|
||||
// go test ./internal/repository/ -run LiveAdminPaging -v
|
||||
|
||||
// walk pages a list to the end, one row per page, the way the panel does.
|
||||
func walk(t *testing.T, fetch func(cursor string) ([]uuid.UUID, models.Pagination)) []uuid.UUID {
|
||||
t.Helper()
|
||||
var seen []uuid.UUID
|
||||
cursor := ""
|
||||
for page := 0; page < 20; page++ {
|
||||
ids, p := fetch(cursor)
|
||||
seen = append(seen, ids...)
|
||||
if !p.HasMore {
|
||||
if p.NextCursor != nil {
|
||||
t.Fatalf("last page still carries next_cursor %q", *p.NextCursor)
|
||||
}
|
||||
return seen
|
||||
}
|
||||
if p.NextCursor == nil {
|
||||
t.Fatal("has_more with no next_cursor")
|
||||
}
|
||||
cursor = *p.NextCursor
|
||||
}
|
||||
t.Fatal("list never ended")
|
||||
return nil
|
||||
}
|
||||
|
||||
func offsetOf(t *testing.T, cursor string) int {
|
||||
t.Helper()
|
||||
offset, xerr := paging.DecodeOffsetCursor(cursor)
|
||||
if xerr != nil {
|
||||
t.Fatalf("next_cursor %q does not decode as an offset: %v", cursor, xerr)
|
||||
}
|
||||
return offset
|
||||
}
|
||||
|
||||
func sameSet(t *testing.T, what string, got, want []uuid.UUID) {
|
||||
t.Helper()
|
||||
g := append([]uuid.UUID(nil), got...)
|
||||
w := append([]uuid.UUID(nil), want...)
|
||||
sort.Slice(g, func(i, j int) bool { return g[i].String() < g[j].String() })
|
||||
sort.Slice(w, func(i, j int) bool { return w[i].String() < w[j].String() })
|
||||
if len(g) != len(w) {
|
||||
t.Fatalf("%s: walked %d rows %v, want %d %v", what, len(g), g, len(w), w)
|
||||
}
|
||||
for i := range g {
|
||||
if g[i] != w[i] {
|
||||
t.Fatalf("%s: walked %v, want %v", what, g, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiveAdminPagingWalksEveryRowOnce(t *testing.T) {
|
||||
_, pool := liveContactDB(t)
|
||||
ctx := context.Background()
|
||||
f := newAdminFixture(t, pool)
|
||||
admin := NewAdminRepository(pool)
|
||||
|
||||
exec := func(sql string, args ...any) {
|
||||
t.Helper()
|
||||
if _, err := pool.Exec(ctx, sql, args...); err != nil {
|
||||
t.Fatalf("fixture %q: %v", sql[:min(60, len(sql))], err)
|
||||
}
|
||||
}
|
||||
|
||||
users := []uuid.UUID{f.user}
|
||||
mailboxes := []uuid.UUID{f.mailbox}
|
||||
campaigns := []uuid.UUID{f.campaign}
|
||||
for i := 0; i < 3; i++ {
|
||||
u, mb, c := uuid.New(), uuid.New(), uuid.New()
|
||||
exec(`INSERT INTO users (id, first_name, last_name, email, password_hash)
|
||||
VALUES ($1, 'Paging', 'Live', $2, 'x')`, u, f.tag+"-u"+u.String()[:6]+"@test.local")
|
||||
t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, u) })
|
||||
exec(`INSERT INTO email_accounts (id, user_id, organization_id, email, name, signature_plain, signature_html, provider)
|
||||
VALUES ($1, $2, $3, $4, 'Paging', '', '', 'smtp_imap')`, mb, f.user, f.org, f.tag+"-mb"+mb.String()[:6]+"@test.local")
|
||||
exec(`INSERT INTO campaigns (id, user_id, organization_id, name, description, days, status, updated_at, created_at)
|
||||
VALUES ($1, $2, $3, 'Paging', '', 62, 'active', NOW(), NOW())`, c, f.user, f.org)
|
||||
users = append(users, u)
|
||||
mailboxes = append(mailboxes, mb)
|
||||
campaigns = append(campaigns, c)
|
||||
}
|
||||
|
||||
// One instant for every audit row, so only the id tiebreak orders them.
|
||||
at := time.Now().UTC().Truncate(time.Second)
|
||||
var audits []uuid.UUID
|
||||
for i := 0; i < 4; i++ {
|
||||
id := uuid.New()
|
||||
exec(`INSERT INTO admin_audit_logs (id, admin_user_id, action, target_type, target_id, created_at)
|
||||
VALUES ($1, $2, 'paging', 'user', $2, $3)`, id, f.user, at)
|
||||
audits = append(audits, id)
|
||||
}
|
||||
|
||||
t.Run("users", func(t *testing.T) {
|
||||
got := walk(t, func(cursor string) ([]uuid.UUID, models.Pagination) {
|
||||
res, err := admin.SearchUsers(ctx, &models.AdminUserSearch{Query: f.tag, Status: "all", Limit: 1, Offset: offsetOf(t, cursor)})
|
||||
if err != nil {
|
||||
t.Fatalf("SearchUsers: %v", err)
|
||||
}
|
||||
var ids []uuid.UUID
|
||||
for _, u := range res.Data {
|
||||
ids = append(ids, u.ID)
|
||||
}
|
||||
return ids, res.Pagination
|
||||
})
|
||||
sameSet(t, "users", got, users)
|
||||
})
|
||||
|
||||
// Every explorer's "before" bound adds a day to a bare parameter, which
|
||||
// Postgres types as an interval unless the query casts it.
|
||||
t.Run("before and after date filters", func(t *testing.T) {
|
||||
today := at.Truncate(24 * time.Hour)
|
||||
org := &models.ParamUUID{UUID: f.org}
|
||||
res, err := admin.SearchUsers(ctx, &models.AdminUserSearch{Query: f.tag, Status: "all", CreatedAfter: &today, CreatedBefore: &today, Limit: 50})
|
||||
if err != nil {
|
||||
t.Fatalf("SearchUsers: %v", err)
|
||||
}
|
||||
if len(res.Data) != len(users) {
|
||||
t.Fatalf("users created today: %d, want %d", len(res.Data), len(users))
|
||||
}
|
||||
if _, err := admin.SearchCampaigns(ctx, &models.AdminCampaignSearch{OrgID: org, CreatedBefore: &today, Limit: 1}); err != nil {
|
||||
t.Fatalf("SearchCampaigns: %v", err)
|
||||
}
|
||||
if _, err := admin.SearchMailboxesForAdmin(ctx, &models.AdminMailboxSearch{OrgID: org, CreatedBefore: &today, Limit: 1}); err != nil {
|
||||
t.Fatalf("SearchMailboxesForAdmin: %v", err)
|
||||
}
|
||||
orgs := NewOrganizationRepository(pool)
|
||||
if _, err := orgs.SearchOrganizationsForAdmin(ctx, &models.AdminOrgSearch{CreatedBefore: &today, Limit: 1}); err != nil {
|
||||
t.Fatalf("SearchOrganizationsForAdmin: %v", err)
|
||||
}
|
||||
if _, err := orgs.ListLimitRequestsForAdmin(ctx, &models.AdminLimitRequestSearch{SubmittedBefore: &today, Limit: 1}); err != nil {
|
||||
t.Fatalf("ListLimitRequestsForAdmin: %v", err)
|
||||
}
|
||||
if _, err := NewAdminOutreachRepository(pool).Search(ctx, &models.AdminOutreachSearch{CreatedBefore: &today, Limit: 1}); err != nil {
|
||||
t.Fatalf("outreach Search: %v", err)
|
||||
}
|
||||
if _, err := NewDiscountCodeRepository(pool).List(ctx, &models.AdminDiscountSearch{CreatedBefore: &today, Limit: 1}); err != nil {
|
||||
t.Fatalf("discount List: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("users sorted by name", func(t *testing.T) {
|
||||
got := walk(t, func(cursor string) ([]uuid.UUID, models.Pagination) {
|
||||
res, err := admin.SearchUsers(ctx, &models.AdminUserSearch{Query: f.tag, Status: "all", SortBy: "name", SortDesc: true, Limit: 1, Offset: offsetOf(t, cursor)})
|
||||
if err != nil {
|
||||
t.Fatalf("SearchUsers: %v", err)
|
||||
}
|
||||
var ids []uuid.UUID
|
||||
for _, u := range res.Data {
|
||||
ids = append(ids, u.ID)
|
||||
}
|
||||
return ids, res.Pagination
|
||||
})
|
||||
sameSet(t, "users by name", got, users)
|
||||
})
|
||||
|
||||
t.Run("users oldest first", func(t *testing.T) {
|
||||
var order []uuid.UUID
|
||||
got := walk(t, func(cursor string) ([]uuid.UUID, models.Pagination) {
|
||||
res, err := admin.SearchUsers(ctx, &models.AdminUserSearch{Query: f.tag, Status: "all", SortBy: "created_at", Limit: 1, Offset: offsetOf(t, cursor)})
|
||||
if err != nil {
|
||||
t.Fatalf("SearchUsers: %v", err)
|
||||
}
|
||||
for _, u := range res.Data {
|
||||
order = append(order, u.ID)
|
||||
}
|
||||
return order[len(order)-len(res.Data):], res.Pagination
|
||||
})
|
||||
sameSet(t, "users oldest first", got, users)
|
||||
if order[0] != f.user {
|
||||
t.Fatalf("ascending created_at starts at %s, want the fixture's first user %s", order[0], f.user)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("campaigns", func(t *testing.T) {
|
||||
org := &models.ParamUUID{UUID: f.org}
|
||||
got := walk(t, func(cursor string) ([]uuid.UUID, models.Pagination) {
|
||||
res, err := admin.SearchCampaigns(ctx, &models.AdminCampaignSearch{OrgID: org, SortBy: "name", Limit: 1, Offset: offsetOf(t, cursor)})
|
||||
if err != nil {
|
||||
t.Fatalf("SearchCampaigns: %v", err)
|
||||
}
|
||||
var ids []uuid.UUID
|
||||
for _, c := range res.Data {
|
||||
ids = append(ids, c.ID)
|
||||
}
|
||||
return ids, res.Pagination
|
||||
})
|
||||
sameSet(t, "campaigns", got, campaigns)
|
||||
})
|
||||
|
||||
t.Run("mailboxes", func(t *testing.T) {
|
||||
org := &models.ParamUUID{UUID: f.org}
|
||||
got := walk(t, func(cursor string) ([]uuid.UUID, models.Pagination) {
|
||||
res, err := admin.SearchMailboxesForAdmin(ctx, &models.AdminMailboxSearch{OrgID: org, Status: "all", SortBy: "last_synced_at", Limit: 1, Offset: offsetOf(t, cursor)})
|
||||
if err != nil {
|
||||
t.Fatalf("SearchMailboxesForAdmin: %v", err)
|
||||
}
|
||||
var ids []uuid.UUID
|
||||
for _, m := range res.Data {
|
||||
ids = append(ids, m.ID)
|
||||
}
|
||||
return ids, res.Pagination
|
||||
})
|
||||
sameSet(t, "mailboxes", got, mailboxes)
|
||||
})
|
||||
|
||||
t.Run("user mailboxes", func(t *testing.T) {
|
||||
got := walk(t, func(cursor string) ([]uuid.UUID, models.Pagination) {
|
||||
emails, p, err := admin.GetUserEmails(ctx, f.user, offsetOf(t, cursor), 1)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserEmails: %v", err)
|
||||
}
|
||||
var ids []uuid.UUID
|
||||
for _, e := range emails {
|
||||
ids = append(ids, e.ID)
|
||||
}
|
||||
return ids, *p
|
||||
})
|
||||
sameSet(t, "user mailboxes", got, mailboxes)
|
||||
})
|
||||
|
||||
t.Run("audit log", func(t *testing.T) {
|
||||
actor := &models.ParamUUID{UUID: f.user}
|
||||
day := at.Truncate(24 * time.Hour)
|
||||
got := walk(t, func(cursor string) ([]uuid.UUID, models.Pagination) {
|
||||
res, err := admin.SearchAuditLogs(ctx, &models.AdminAuditLogSearch{AdminUserID: actor, StartDate: &day, EndDate: &day, Cursor: cursor, Limit: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("SearchAuditLogs: %v", err)
|
||||
}
|
||||
var ids []uuid.UUID
|
||||
for _, l := range res.Data {
|
||||
ids = append(ids, l.ID)
|
||||
}
|
||||
return ids, res.Pagination
|
||||
})
|
||||
// An end date names the whole day, so rows written today are inside it.
|
||||
sameSet(t, "audit log", got, audits)
|
||||
})
|
||||
|
||||
t.Run("organizations, limit requests and outreach page past the first", func(t *testing.T) {
|
||||
orgs := NewOrganizationRepository(pool)
|
||||
if _, err := orgs.SearchOrganizationsForAdmin(ctx, &models.AdminOrgSearch{Status: "all", Limit: 1, Offset: 1}); err != nil {
|
||||
t.Fatalf("SearchOrganizationsForAdmin: %v", err)
|
||||
}
|
||||
if _, err := orgs.ListLimitRequestsForAdmin(ctx, &models.AdminLimitRequestSearch{Status: "all", Limit: 1, Offset: 1}); err != nil {
|
||||
t.Fatalf("ListLimitRequestsForAdmin: %v", err)
|
||||
}
|
||||
if _, err := NewAdminOutreachRepository(pool).Search(ctx, &models.AdminOutreachSearch{Limit: 1, Offset: 1}); err != nil {
|
||||
t.Fatalf("outreach Search: %v", err)
|
||||
}
|
||||
for name, call := range map[string]func() error{
|
||||
"ListAdmins": func() error { _, err := admin.ListAdmins(ctx, 1, 1); return err },
|
||||
"ListWorkers": func() error { _, err := admin.ListWorkers(ctx, 1, 1); return err },
|
||||
"GetPoolParticipants": func() error { _, err := admin.GetPoolParticipants(ctx, "free", 1, 1); return err },
|
||||
"ListBlockedAccounts": func() error { _, err := admin.ListBlockedAccounts(ctx, 1, 1); return err },
|
||||
"ListAppeals": func() error { _, err := admin.ListAppeals(ctx, "", 1, 1); return err },
|
||||
} {
|
||||
if err := call(); err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -112,7 +112,7 @@ func TestLiveAdminUserPreviewListsMailboxes(t *testing.T) {
|
||||
|
||||
// The paginated mailbox list behind the user detail page compared the same
|
||||
// uuid column against a text parameter, and that one surfaced as a 500.
|
||||
emails, pagination, err := repo.GetUserEmails(context.Background(), f.user, nil, 50)
|
||||
emails, pagination, err := repo.GetUserEmails(context.Background(), f.user, 0, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserEmails: %v", err)
|
||||
}
|
||||
|
||||
+73
-118
@@ -33,11 +33,11 @@ type AdminRepository interface {
|
||||
BanUser(ctx context.Context, userID, bannedBy uuid.UUID, reason string, scope uint32) error
|
||||
UnbanUser(ctx context.Context, userID, unbannedBy uuid.UUID, reason string) error
|
||||
GetUserBans(ctx context.Context, userID uuid.UUID) ([]models.UserBan, error)
|
||||
GetUserEmails(ctx context.Context, userID uuid.UUID, cursor *uuid.UUID, limit int) ([]models.AdminWorkerEmail, *models.Pagination, error)
|
||||
ListAdmins(ctx context.Context, cursor *uuid.UUID, limit int) (*models.AdminsResult, error)
|
||||
GetUserEmails(ctx context.Context, userID uuid.UUID, offset, limit int) ([]models.AdminWorkerEmail, *models.Pagination, error)
|
||||
ListAdmins(ctx context.Context, offset, limit int) (*models.AdminsResult, error)
|
||||
|
||||
// Worker Management
|
||||
ListWorkers(ctx context.Context, cursor *uuid.UUID, limit int) (*models.AdminWorkersResult, error)
|
||||
ListWorkers(ctx context.Context, offset, limit int) (*models.AdminWorkersResult, error)
|
||||
GetWorkerDetail(ctx context.Context, workerID uuid.UUID) (*models.AdminWorkerDetail, error)
|
||||
UpdateWorker(ctx context.Context, workerID uuid.UUID, update *models.AdminUpdateWorker) error
|
||||
GetWorkerEmails(ctx context.Context, workerID uuid.UUID, beforeAt time.Time, beforeID uuid.UUID, limit int) ([]models.AdminWorkerEmail, *models.Pagination, error)
|
||||
@@ -46,13 +46,13 @@ type AdminRepository interface {
|
||||
|
||||
// Warmup Management
|
||||
ListWarmupPools(ctx context.Context) ([]models.WarmupPoolInfo, error)
|
||||
GetPoolParticipants(ctx context.Context, poolType string, cursor *uuid.UUID, limit int) (*models.WarmupPoolParticipantsResult, error)
|
||||
ListBlockedAccounts(ctx context.Context, cursor *uuid.UUID, limit int) (*models.AdminBlockedAccountsResult, error)
|
||||
GetPoolParticipants(ctx context.Context, poolType string, offset, limit int) (*models.WarmupPoolParticipantsResult, error)
|
||||
ListBlockedAccounts(ctx context.Context, offset, limit int) (*models.AdminBlockedAccountsResult, error)
|
||||
BlockAccount(ctx context.Context, accountID uuid.UUID, blockedBy uuid.UUID, reason string) error
|
||||
UnblockAccount(ctx context.Context, accountID uuid.UUID) error
|
||||
|
||||
// Warmup Appeals
|
||||
ListAppeals(ctx context.Context, status string, cursor *uuid.UUID, limit int) (*models.WarmupAppealsResult, error)
|
||||
ListAppeals(ctx context.Context, status string, offset, limit int) (*models.WarmupAppealsResult, error)
|
||||
GetAppeal(ctx context.Context, appealID uuid.UUID) (*models.WarmupAppeal, error)
|
||||
ReviewAppeal(ctx context.Context, appealID uuid.UUID, reviewedBy uuid.UUID, approved bool, notes string) error
|
||||
|
||||
@@ -152,7 +152,7 @@ func (r *adminRepository) SearchUsers(ctx context.Context, search *models.AdminU
|
||||
}
|
||||
addBefore := func(col string, v *time.Time) {
|
||||
if v != nil {
|
||||
whereClause += " AND " + col + " < ($" + itoa(argNum) + " + INTERVAL '1 day')"
|
||||
whereClause += " AND " + col + " < ($" + itoa(argNum) + "::timestamptz + INTERVAL '1 day')"
|
||||
args = append(args, *v)
|
||||
argNum++
|
||||
}
|
||||
@@ -161,7 +161,7 @@ func (r *adminRepository) SearchUsers(ctx context.Context, search *models.AdminU
|
||||
// Plan / subscription
|
||||
if search.PlanID != nil {
|
||||
whereClause += ` AND EXISTS (SELECT 1 FROM subscriptions s WHERE s.user_id = u.id AND s.plan_id = $` + itoa(argNum) + `)`
|
||||
args = append(args, *search.PlanID)
|
||||
args = append(args, search.PlanID.UUID)
|
||||
argNum++
|
||||
}
|
||||
if search.SubscriptionStatus != "" {
|
||||
@@ -219,22 +219,21 @@ func (r *adminRepository) SearchUsers(ctx context.Context, search *models.AdminU
|
||||
addAfter("u.updated_at", search.UpdatedAfter)
|
||||
addBefore("u.updated_at", search.UpdatedBefore)
|
||||
|
||||
if search.Cursor != nil {
|
||||
whereClause += ` AND u.id < $` + itoa(argNum)
|
||||
args = append(args, *search.Cursor)
|
||||
argNum++
|
||||
}
|
||||
offset := search.Offset
|
||||
|
||||
orderBy := "ORDER BY u.created_at DESC"
|
||||
orderBy := "ORDER BY u.created_at DESC, u.id DESC"
|
||||
if search.SortBy != "" {
|
||||
switch search.SortBy {
|
||||
case "email":
|
||||
orderBy = "ORDER BY u.email"
|
||||
case "name":
|
||||
orderBy = "ORDER BY u.first_name, u.last_name"
|
||||
}
|
||||
dir := " ASC"
|
||||
if search.SortDesc {
|
||||
orderBy += " DESC"
|
||||
dir = " DESC"
|
||||
}
|
||||
switch search.SortBy {
|
||||
case "created_at":
|
||||
orderBy = "ORDER BY u.created_at" + dir + ", u.id" + dir
|
||||
case "email":
|
||||
orderBy = "ORDER BY u.email" + dir + ", u.id" + dir
|
||||
case "name":
|
||||
orderBy = "ORDER BY u.first_name" + dir + ", u.last_name" + dir + ", u.id" + dir
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,7 +250,7 @@ func (r *adminRepository) SearchUsers(ctx context.Context, search *models.AdminU
|
||||
FROM users u
|
||||
` + whereClause + `
|
||||
` + orderBy + `
|
||||
LIMIT $` + itoa(argNum)
|
||||
` + adminLimitOffset("$"+itoa(argNum), offset)
|
||||
|
||||
rows, err := r.db.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
@@ -286,8 +285,7 @@ func (r *adminRepository) SearchUsers(ctx context.Context, search *models.AdminU
|
||||
|
||||
if len(users) > limit {
|
||||
result.Data = users[:limit]
|
||||
lastID := users[limit-1].ID
|
||||
result.Pagination.NextCursor = paging.UUIDString(lastID)
|
||||
result.Pagination.NextCursor = adminNextCursor(offset, limit)
|
||||
}
|
||||
|
||||
// Get total count
|
||||
@@ -604,7 +602,7 @@ func (r *adminRepository) GetUserBans(ctx context.Context, userID uuid.UUID) ([]
|
||||
}
|
||||
|
||||
// GetUserEmails gets email accounts belonging to a user
|
||||
func (r *adminRepository) GetUserEmails(ctx context.Context, userID uuid.UUID, cursor *uuid.UUID, limit int) ([]models.AdminWorkerEmail, *models.Pagination, error) {
|
||||
func (r *adminRepository) GetUserEmails(ctx context.Context, userID uuid.UUID, offset, limit int) ([]models.AdminWorkerEmail, *models.Pagination, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 50
|
||||
}
|
||||
@@ -612,19 +610,14 @@ func (r *adminRepository) GetUserEmails(ctx context.Context, userID uuid.UUID, c
|
||||
args := []interface{}{userID, limit + 1}
|
||||
// email_accounts.user_id is uuid; casting it to text made every call fail.
|
||||
whereClause := "WHERE ea.user_id = $1"
|
||||
if cursor != nil {
|
||||
whereClause += " AND ea.id < $3"
|
||||
args = append(args, *cursor)
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT ea.id, ea.email, ea.user_id, ea.organization_id,
|
||||
ea.status, ea.provider, ea.warmup IS NOT NULL, ea.last_synced_at
|
||||
FROM email_accounts ea
|
||||
` + whereClause + `
|
||||
ORDER BY ea.created_at DESC
|
||||
LIMIT $2
|
||||
`
|
||||
ORDER BY ea.created_at DESC, ea.id DESC
|
||||
` + adminLimitOffset("$2", offset)
|
||||
|
||||
rows, err := r.db.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
@@ -651,24 +644,20 @@ func (r *adminRepository) GetUserEmails(ctx context.Context, userID uuid.UUID, c
|
||||
|
||||
if len(emails) > limit {
|
||||
emails = emails[:limit]
|
||||
pagination.NextCursor = paging.UUIDString(emails[limit-1].ID)
|
||||
pagination.NextCursor = adminNextCursor(offset, limit)
|
||||
}
|
||||
|
||||
return emails, pagination, nil
|
||||
}
|
||||
|
||||
// ListAdmins lists all admin users
|
||||
func (r *adminRepository) ListAdmins(ctx context.Context, cursor *uuid.UUID, limit int) (*models.AdminsResult, error) {
|
||||
func (r *adminRepository) ListAdmins(ctx context.Context, offset, limit int) (*models.AdminsResult, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
args := []interface{}{limit + 1}
|
||||
whereClause := "WHERE u.admin_permissions > 0"
|
||||
if cursor != nil {
|
||||
whereClause += " AND u.id < $2"
|
||||
args = append(args, *cursor)
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT u.id, u.first_name, u.last_name, u.email, u.admin_permissions,
|
||||
@@ -677,9 +666,8 @@ func (r *adminRepository) ListAdmins(ctx context.Context, cursor *uuid.UUID, lim
|
||||
FROM users u
|
||||
LEFT JOIN users gu ON gu.id = u.admin_granted_by
|
||||
` + whereClause + `
|
||||
ORDER BY u.admin_granted_at DESC
|
||||
LIMIT $1
|
||||
`
|
||||
ORDER BY u.admin_granted_at DESC NULLS LAST, u.id DESC
|
||||
` + adminLimitOffset("$1", offset)
|
||||
|
||||
rows, err := r.db.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
@@ -723,25 +711,19 @@ func (r *adminRepository) ListAdmins(ctx context.Context, cursor *uuid.UUID, lim
|
||||
|
||||
if len(admins) > limit {
|
||||
result.Data = admins[:limit]
|
||||
lastID := admins[limit-1].ID
|
||||
result.Pagination.NextCursor = paging.UUIDString(lastID)
|
||||
result.Pagination.NextCursor = adminNextCursor(offset, limit)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ListWorkers lists all workers with details
|
||||
func (r *adminRepository) ListWorkers(ctx context.Context, cursor *uuid.UUID, limit int) (*models.AdminWorkersResult, error) {
|
||||
func (r *adminRepository) ListWorkers(ctx context.Context, offset, limit int) (*models.AdminWorkersResult, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
args := []interface{}{limit + 1}
|
||||
whereClause := ""
|
||||
if cursor != nil {
|
||||
whereClause = "WHERE w.id < $2"
|
||||
args = append(args, *cursor)
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT w.id, n.name, n.notes, n.address, n.active,
|
||||
@@ -750,10 +732,8 @@ func (r *adminRepository) ListWorkers(ctx context.Context, cursor *uuid.UUID, li
|
||||
(SELECT COUNT(*) FROM email_accounts ea WHERE ea.worker_id = w.id) as connected_emails
|
||||
FROM workers w
|
||||
JOIN fleet_nodes n ON n.id = w.id
|
||||
` + whereClause + `
|
||||
ORDER BY w.created_at DESC
|
||||
LIMIT $1
|
||||
`
|
||||
ORDER BY w.created_at DESC, w.id DESC
|
||||
` + adminLimitOffset("$1", offset)
|
||||
|
||||
rows, err := r.db.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
@@ -785,8 +765,7 @@ func (r *adminRepository) ListWorkers(ctx context.Context, cursor *uuid.UUID, li
|
||||
|
||||
if len(workers) > limit {
|
||||
result.Data = workers[:limit]
|
||||
lastID := workers[limit-1].ID
|
||||
result.Pagination.NextCursor = paging.UUIDString(lastID)
|
||||
result.Pagination.NextCursor = adminNextCursor(offset, limit)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
@@ -1020,17 +999,13 @@ func (r *adminRepository) ListWarmupPools(ctx context.Context) ([]models.WarmupP
|
||||
}
|
||||
|
||||
// GetPoolParticipants gets participants in a warmup pool
|
||||
func (r *adminRepository) GetPoolParticipants(ctx context.Context, poolType string, cursor *uuid.UUID, limit int) (*models.WarmupPoolParticipantsResult, error) {
|
||||
func (r *adminRepository) GetPoolParticipants(ctx context.Context, poolType string, offset, limit int) (*models.WarmupPoolParticipantsResult, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
args := []interface{}{poolType, limit + 1}
|
||||
whereClause := "WHERE wp.pool_type = $1::warmup_pool_type"
|
||||
if cursor != nil {
|
||||
whereClause += " AND wpp.email_account_id < $3"
|
||||
args = append(args, *cursor)
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT
|
||||
@@ -1044,9 +1019,8 @@ func (r *adminRepository) GetPoolParticipants(ctx context.Context, poolType stri
|
||||
JOIN warmup_pools wp ON wpp.pool_id = wp.id
|
||||
JOIN email_accounts ea ON ea.id = wpp.email_account_id
|
||||
` + whereClause + `
|
||||
ORDER BY wpp.joined_at DESC
|
||||
LIMIT $2
|
||||
`
|
||||
ORDER BY wpp.joined_at DESC, wpp.email_account_id DESC
|
||||
` + adminLimitOffset("$2", offset)
|
||||
|
||||
rows, err := r.db.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
@@ -1076,24 +1050,20 @@ func (r *adminRepository) GetPoolParticipants(ctx context.Context, poolType stri
|
||||
}
|
||||
if len(participants) > limit {
|
||||
result.Data = participants[:limit]
|
||||
result.Pagination.NextCursor = paging.UUIDString(participants[limit-1].ID)
|
||||
result.Pagination.NextCursor = adminNextCursor(offset, limit)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ListBlockedAccounts lists blocked warmup accounts
|
||||
func (r *adminRepository) ListBlockedAccounts(ctx context.Context, cursor *uuid.UUID, limit int) (*models.AdminBlockedAccountsResult, error) {
|
||||
func (r *adminRepository) ListBlockedAccounts(ctx context.Context, offset, limit int) (*models.AdminBlockedAccountsResult, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
args := []interface{}{limit + 1}
|
||||
whereClause := `WHERE (wpp.health_state IN ('quarantined', 'blocked') OR wpp.blocked_at IS NOT NULL)`
|
||||
if cursor != nil {
|
||||
whereClause += " AND wpp.email_account_id < $2"
|
||||
args = append(args, *cursor)
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT
|
||||
@@ -1107,9 +1077,8 @@ func (r *adminRepository) ListBlockedAccounts(ctx context.Context, cursor *uuid.
|
||||
JOIN email_accounts ea ON ea.id = wpp.email_account_id
|
||||
JOIN users u ON u.id = ea.user_id::uuid
|
||||
` + whereClause + `
|
||||
ORDER BY blocked_at DESC
|
||||
LIMIT $1
|
||||
`
|
||||
ORDER BY blocked_at DESC, wpp.email_account_id DESC
|
||||
` + adminLimitOffset("$1", offset)
|
||||
|
||||
rows, err := r.db.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
@@ -1147,7 +1116,7 @@ func (r *adminRepository) ListBlockedAccounts(ctx context.Context, cursor *uuid.
|
||||
}
|
||||
if len(accounts) > limit {
|
||||
result.Data = accounts[:limit]
|
||||
result.Pagination.NextCursor = paging.UUIDString(accounts[limit-1].ID)
|
||||
result.Pagination.NextCursor = adminNextCursor(offset, limit)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
@@ -1205,7 +1174,7 @@ func (r *adminRepository) UnblockAccount(ctx context.Context, accountID uuid.UUI
|
||||
}
|
||||
|
||||
// ListAppeals lists warmup appeals
|
||||
func (r *adminRepository) ListAppeals(ctx context.Context, status string, cursor *uuid.UUID, limit int) (*models.WarmupAppealsResult, error) {
|
||||
func (r *adminRepository) ListAppeals(ctx context.Context, status string, offset, limit int) (*models.WarmupAppealsResult, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 50
|
||||
}
|
||||
@@ -1220,11 +1189,6 @@ func (r *adminRepository) ListAppeals(ctx context.Context, status string, cursor
|
||||
argNum++
|
||||
}
|
||||
|
||||
if cursor != nil {
|
||||
whereClause += " AND wa.id < $" + itoa(argNum)
|
||||
args = append(args, *cursor)
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT wa.id, wa.email_account_id, wa.user_id, wa.reason, wa.status,
|
||||
wa.reviewed_by, wa.reviewed_at, wa.review_notes, wa.created_at,
|
||||
@@ -1232,9 +1196,8 @@ func (r *adminRepository) ListAppeals(ctx context.Context, status string, cursor
|
||||
FROM warmup_appeals wa
|
||||
JOIN users u ON u.id = wa.user_id
|
||||
` + whereClause + `
|
||||
ORDER BY wa.created_at DESC
|
||||
LIMIT $1
|
||||
`
|
||||
ORDER BY wa.created_at DESC, wa.id DESC
|
||||
` + adminLimitOffset("$1", offset)
|
||||
|
||||
rows, err := r.db.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
@@ -1269,8 +1232,7 @@ func (r *adminRepository) ListAppeals(ctx context.Context, status string, cursor
|
||||
|
||||
if len(appeals) > limit {
|
||||
result.Data = appeals[:limit]
|
||||
lastID := appeals[limit-1].ID
|
||||
result.Pagination.NextCursor = paging.UUIDString(lastID)
|
||||
result.Pagination.NextCursor = adminNextCursor(offset, limit)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
@@ -1378,13 +1340,13 @@ func (r *adminRepository) SearchCampaigns(ctx context.Context, search *models.Ad
|
||||
|
||||
if search.UserID != nil {
|
||||
whereClause += " AND c.user_id = $" + itoa(argNum)
|
||||
args = append(args, *search.UserID)
|
||||
args = append(args, search.UserID.UUID)
|
||||
argNum++
|
||||
}
|
||||
|
||||
if search.OrgID != nil {
|
||||
whereClause += " AND c.organization_id = $" + itoa(argNum)
|
||||
args = append(args, *search.OrgID)
|
||||
args = append(args, search.OrgID.UUID)
|
||||
argNum++
|
||||
}
|
||||
|
||||
@@ -1435,7 +1397,7 @@ func (r *adminRepository) SearchCampaigns(ctx context.Context, search *models.Ad
|
||||
}
|
||||
addBefore := func(col string, v *time.Time) {
|
||||
if v != nil {
|
||||
whereClause += " AND " + col + " < ($" + itoa(argNum) + " + INTERVAL '1 day')"
|
||||
whereClause += " AND " + col + " < ($" + itoa(argNum) + "::timestamptz + INTERVAL '1 day')"
|
||||
args = append(args, *v)
|
||||
argNum++
|
||||
}
|
||||
@@ -1460,11 +1422,7 @@ func (r *adminRepository) SearchCampaigns(ctx context.Context, search *models.Ad
|
||||
addAfter("c.updated_at", search.UpdatedAfter)
|
||||
addBefore("c.updated_at", search.UpdatedBefore)
|
||||
|
||||
if search.Cursor != nil {
|
||||
whereClause += " AND c.id < $" + itoa(argNum)
|
||||
args = append(args, *search.Cursor)
|
||||
argNum++
|
||||
}
|
||||
offset := search.Offset
|
||||
|
||||
orderCol := "c.created_at"
|
||||
switch search.SortBy {
|
||||
@@ -1507,7 +1465,7 @@ func (r *adminRepository) SearchCampaigns(ctx context.Context, search *models.Ad
|
||||
LEFT JOIN organizations o ON o.id = c.organization_id
|
||||
` + whereClause + `
|
||||
` + orderBy + `
|
||||
LIMIT $` + itoa(argNum)
|
||||
` + adminLimitOffset("$"+itoa(argNum), offset)
|
||||
|
||||
rows, err := r.db.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
@@ -1554,8 +1512,7 @@ func (r *adminRepository) SearchCampaigns(ctx context.Context, search *models.Ad
|
||||
|
||||
if len(campaigns) > limit {
|
||||
result.Data = campaigns[:limit]
|
||||
lastID := campaigns[limit-1].ID
|
||||
result.Pagination.NextCursor = paging.UUIDString(lastID)
|
||||
result.Pagination.NextCursor = adminNextCursor(offset, limit)
|
||||
}
|
||||
|
||||
// Total count for the same filter — drop the trailing LIMIT arg.
|
||||
@@ -1643,7 +1600,7 @@ func (r *adminRepository) SearchAuditLogs(ctx context.Context, search *models.Ad
|
||||
|
||||
if search.AdminUserID != nil {
|
||||
whereClause += " AND al.admin_user_id = $" + itoa(argNum)
|
||||
args = append(args, *search.AdminUserID)
|
||||
args = append(args, search.AdminUserID.UUID)
|
||||
argNum++
|
||||
}
|
||||
|
||||
@@ -1661,7 +1618,7 @@ func (r *adminRepository) SearchAuditLogs(ctx context.Context, search *models.Ad
|
||||
|
||||
if search.TargetID != nil {
|
||||
whereClause += " AND al.target_id = $" + itoa(argNum)
|
||||
args = append(args, *search.TargetID)
|
||||
args = append(args, search.TargetID.UUID)
|
||||
argNum++
|
||||
}
|
||||
|
||||
@@ -1672,27 +1629,30 @@ func (r *adminRepository) SearchAuditLogs(ctx context.Context, search *models.Ad
|
||||
}
|
||||
|
||||
if search.EndDate != nil {
|
||||
whereClause += " AND al.created_at <= $" + itoa(argNum)
|
||||
// The end date names a whole day, so the bound is the next midnight.
|
||||
whereClause += " AND al.created_at < ($" + itoa(argNum) + "::timestamptz + INTERVAL '1 day')"
|
||||
args = append(args, *search.EndDate)
|
||||
argNum++
|
||||
}
|
||||
|
||||
if search.Cursor != nil {
|
||||
whereClause += " AND al.id < $" + itoa(argNum)
|
||||
args = append(args, *search.Cursor)
|
||||
argNum++
|
||||
// The log only grows, so it pages on its own order rather than an offset
|
||||
// that shifts under an operator who is watching it fill.
|
||||
if at, id, xerr := paging.DecodeTimeCursor(search.Cursor); xerr == nil && id != uuid.Nil {
|
||||
whereClause += " AND (al.created_at, al.id) < ($" + itoa(argNum) + "::timestamptz, $" + itoa(argNum+1) + "::uuid)"
|
||||
args = append(args, at, id)
|
||||
argNum += 2
|
||||
}
|
||||
|
||||
args = append(args, limit+1)
|
||||
|
||||
query := `
|
||||
SELECT al.id, al.admin_user_id, al.action, al.target_type, al.target_id,
|
||||
al.details, al.ip_address, al.user_agent, al.created_at,
|
||||
al.details, COALESCE(al.ip_address, ''), COALESCE(al.user_agent, ''), al.created_at,
|
||||
u.id, u.first_name, u.last_name, u.email
|
||||
FROM admin_audit_logs al
|
||||
JOIN users u ON u.id = al.admin_user_id
|
||||
` + whereClause + `
|
||||
ORDER BY al.created_at DESC
|
||||
ORDER BY al.created_at DESC, al.id DESC
|
||||
LIMIT $` + itoa(argNum)
|
||||
|
||||
rows, err := r.db.Query(ctx, query, args...)
|
||||
@@ -1733,8 +1693,8 @@ func (r *adminRepository) SearchAuditLogs(ctx context.Context, search *models.Ad
|
||||
|
||||
if len(logs) > limit {
|
||||
result.Data = logs[:limit]
|
||||
lastID := logs[limit-1].ID
|
||||
result.Pagination.NextCursor = paging.UUIDString(lastID)
|
||||
last := logs[limit-1]
|
||||
result.Pagination.NextCursor = paging.EncodeTime(last.CreatedAt, last.ID)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
@@ -1964,7 +1924,7 @@ func (r *adminRepository) SearchMailboxesForAdmin(ctx context.Context, search *m
|
||||
}
|
||||
if search.OrgID != nil {
|
||||
where += " AND ea.organization_id = $" + itoa(argNum)
|
||||
args = append(args, *search.OrgID)
|
||||
args = append(args, search.OrgID.UUID)
|
||||
argNum++
|
||||
}
|
||||
|
||||
@@ -1985,7 +1945,7 @@ func (r *adminRepository) SearchMailboxesForAdmin(ctx context.Context, search *m
|
||||
}
|
||||
addBefore := func(col string, v *time.Time) {
|
||||
if v != nil {
|
||||
where += " AND " + col + " < ($" + itoa(argNum) + " + INTERVAL '1 day')"
|
||||
where += " AND " + col + " < ($" + itoa(argNum) + "::timestamptz + INTERVAL '1 day')"
|
||||
args = append(args, *v)
|
||||
argNum++
|
||||
}
|
||||
@@ -1994,12 +1954,12 @@ func (r *adminRepository) SearchMailboxesForAdmin(ctx context.Context, search *m
|
||||
// Ownership / placement
|
||||
if search.UserID != nil {
|
||||
where += " AND ea.user_id = $" + itoa(argNum)
|
||||
args = append(args, *search.UserID)
|
||||
args = append(args, search.UserID.UUID)
|
||||
argNum++
|
||||
}
|
||||
if search.WorkerID != nil {
|
||||
where += " AND ea.worker_id = $" + itoa(argNum)
|
||||
args = append(args, *search.WorkerID)
|
||||
args = append(args, search.WorkerID.UUID)
|
||||
argNum++
|
||||
}
|
||||
|
||||
@@ -2058,11 +2018,7 @@ func (r *adminRepository) SearchMailboxesForAdmin(ctx context.Context, search *m
|
||||
addAfter("ea.last_synced_at", search.LastSyncedAfter)
|
||||
addBefore("ea.last_synced_at", search.LastSyncedBefore)
|
||||
|
||||
if search.Cursor != nil {
|
||||
where += " AND ea.id < $" + itoa(argNum)
|
||||
args = append(args, *search.Cursor)
|
||||
argNum++
|
||||
}
|
||||
offset := search.Offset
|
||||
|
||||
orderCol := "ea.id"
|
||||
switch search.SortBy {
|
||||
@@ -2100,7 +2056,7 @@ func (r *adminRepository) SearchMailboxesForAdmin(ctx context.Context, search *m
|
||||
LEFT JOIN organizations o ON o.id = ea.organization_id
|
||||
` + where + `
|
||||
` + orderBy + `
|
||||
LIMIT ` + limitParam
|
||||
` + adminLimitOffset(limitParam, offset)
|
||||
|
||||
rows, err := r.db.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
@@ -2131,8 +2087,7 @@ func (r *adminRepository) SearchMailboxesForAdmin(ctx context.Context, search *m
|
||||
}
|
||||
if len(items) > limit {
|
||||
result.Data = items[:limit]
|
||||
last := items[limit-1].ID
|
||||
result.Pagination.NextCursor = paging.UUIDString(last)
|
||||
result.Pagination.NextCursor = adminNextCursor(offset, limit)
|
||||
}
|
||||
|
||||
// Total count for the same filter (drop the trailing LIMIT arg).
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
"github.com/warmbly/warmbly/internal/utils/paging"
|
||||
)
|
||||
|
||||
// AdminOutreachRepository is the persistence layer for the
|
||||
@@ -112,7 +111,7 @@ func (r *adminOutreachRepository) Search(ctx context.Context, search *models.Adm
|
||||
}
|
||||
addBefore := func(col string, v *time.Time) {
|
||||
if v != nil {
|
||||
where += " AND " + col + " < ($" + itoa(argNum) + " + INTERVAL '1 day')"
|
||||
where += " AND " + col + " < ($" + itoa(argNum) + "::timestamptz + INTERVAL '1 day')"
|
||||
args = append(args, *v)
|
||||
argNum++
|
||||
}
|
||||
@@ -127,11 +126,7 @@ func (r *adminOutreachRepository) Search(ctx context.Context, search *models.Adm
|
||||
addAfter("m.sent_at", search.SentAtAfter)
|
||||
addBefore("m.sent_at", search.SentAtBefore)
|
||||
|
||||
if search.Cursor != nil {
|
||||
where += ` AND m.id < $` + itoa(argNum)
|
||||
args = append(args, *search.Cursor)
|
||||
argNum++
|
||||
}
|
||||
offset := search.Offset
|
||||
|
||||
orderCol := "m.created_at"
|
||||
switch search.SortBy {
|
||||
@@ -163,7 +158,7 @@ func (r *adminOutreachRepository) Search(ctx context.Context, search *models.Adm
|
||||
LEFT JOIN users u ON u.id = m.to_user_id
|
||||
` + where + `
|
||||
` + orderBy + `
|
||||
LIMIT $` + itoa(argNum)
|
||||
` + adminLimitOffset("$"+itoa(argNum), offset)
|
||||
|
||||
rows, err := r.db.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
@@ -199,8 +194,7 @@ func (r *adminOutreachRepository) Search(ctx context.Context, search *models.Adm
|
||||
}
|
||||
if len(items) > limit {
|
||||
result.Data = items[:limit]
|
||||
last := items[limit-1].ID
|
||||
result.Pagination.NextCursor = paging.UUIDString(last)
|
||||
result.Pagination.NextCursor = adminNextCursor(offset, limit)
|
||||
}
|
||||
|
||||
countQuery := `SELECT COUNT(*) FROM admin_outreach_messages m JOIN users s ON s.id = m.sent_by LEFT JOIN users u ON u.id = m.to_user_id ` + where
|
||||
|
||||
@@ -242,7 +242,7 @@ func (r *discountCodeRepository) List(ctx context.Context, search *models.AdminD
|
||||
}
|
||||
if search.PlanID != nil {
|
||||
whereClause += " AND EXISTS (SELECT 1 FROM discount_code_plans dcp WHERE dcp.discount_code_id = dc.id AND dcp.plan_id = $" + itoa(argNum) + ")"
|
||||
args = append(args, *search.PlanID)
|
||||
args = append(args, search.PlanID.UUID)
|
||||
argNum++
|
||||
}
|
||||
if search.HasRedemptions {
|
||||
@@ -274,7 +274,7 @@ func (r *discountCodeRepository) List(ctx context.Context, search *models.AdminD
|
||||
}
|
||||
addBefore := func(col string, v *time.Time) {
|
||||
if v != nil {
|
||||
whereClause += " AND " + col + " < ($" + itoa(argNum) + " + INTERVAL '1 day')"
|
||||
whereClause += " AND " + col + " < ($" + itoa(argNum) + "::timestamptz + INTERVAL '1 day')"
|
||||
args = append(args, *v)
|
||||
argNum++
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
"github.com/warmbly/warmbly/internal/utils/paging"
|
||||
)
|
||||
|
||||
// OrganizationRepository defines the interface for organization data access
|
||||
@@ -795,7 +794,7 @@ func (r *organizationRepository) SearchOrganizationsForAdmin(ctx context.Context
|
||||
|
||||
if search.PlanID != nil {
|
||||
where += ` AND s.plan_id = $` + itoa(argNum)
|
||||
args = append(args, *search.PlanID)
|
||||
args = append(args, search.PlanID.UUID)
|
||||
argNum++
|
||||
}
|
||||
switch search.PlanVisibility {
|
||||
@@ -845,7 +844,7 @@ func (r *organizationRepository) SearchOrganizationsForAdmin(ctx context.Context
|
||||
}
|
||||
addBefore := func(col string, v *time.Time) {
|
||||
if v != nil {
|
||||
where += " AND " + col + " < ($" + itoa(argNum) + " + INTERVAL '1 day')"
|
||||
where += " AND " + col + " < ($" + itoa(argNum) + "::timestamptz + INTERVAL '1 day')"
|
||||
args = append(args, *v)
|
||||
argNum++
|
||||
}
|
||||
@@ -919,11 +918,7 @@ func (r *organizationRepository) SearchOrganizationsForAdmin(ctx context.Context
|
||||
addAfter("o.updated_at", search.UpdatedAfter)
|
||||
addBefore("o.updated_at", search.UpdatedBefore)
|
||||
|
||||
if search.Cursor != nil {
|
||||
where += ` AND o.id < $` + itoa(argNum)
|
||||
args = append(args, *search.Cursor)
|
||||
argNum++
|
||||
}
|
||||
offset := search.Offset
|
||||
|
||||
orderCol := "o.created_at"
|
||||
switch search.SortBy {
|
||||
@@ -942,7 +937,7 @@ func (r *organizationRepository) SearchOrganizationsForAdmin(ctx context.Context
|
||||
if search.SortBy != "" && !search.SortDesc {
|
||||
orderDir = "ASC"
|
||||
}
|
||||
orderBy := "ORDER BY " + orderCol + " " + orderDir
|
||||
orderBy := "ORDER BY " + orderCol + " " + orderDir + ", o.id DESC"
|
||||
|
||||
args = append(args, limit+1)
|
||||
|
||||
@@ -956,7 +951,7 @@ func (r *organizationRepository) SearchOrganizationsForAdmin(ctx context.Context
|
||||
LEFT JOIN plans p ON p.id = s.plan_id` + adminOrgAcquisitionJoin + `
|
||||
` + where + `
|
||||
` + orderBy + `
|
||||
LIMIT $` + itoa(argNum)
|
||||
` + adminLimitOffset("$"+itoa(argNum), offset)
|
||||
|
||||
rows, err := r.db.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
@@ -1007,8 +1002,7 @@ func (r *organizationRepository) SearchOrganizationsForAdmin(ctx context.Context
|
||||
}
|
||||
if len(items) > limit {
|
||||
result.Data = items[:limit]
|
||||
last := items[limit-1].ID
|
||||
result.Pagination.NextCursor = paging.UUIDString(last)
|
||||
result.Pagination.NextCursor = adminNextCursor(offset, limit)
|
||||
}
|
||||
|
||||
// Total count for the same filter — drop the trailing LIMIT arg.
|
||||
@@ -1286,12 +1280,12 @@ func (r *organizationRepository) ListLimitRequestsForAdmin(ctx context.Context,
|
||||
}
|
||||
if search.OrgID != nil {
|
||||
where += " AND lr.organization_id = $" + itoa(argNum)
|
||||
args = append(args, *search.OrgID)
|
||||
args = append(args, search.OrgID.UUID)
|
||||
argNum++
|
||||
}
|
||||
if search.SubmittedBy != nil {
|
||||
where += " AND lr.submitted_by = $" + itoa(argNum)
|
||||
args = append(args, *search.SubmittedBy)
|
||||
args = append(args, search.SubmittedBy.UUID)
|
||||
argNum++
|
||||
}
|
||||
if search.Reviewed {
|
||||
@@ -1317,7 +1311,7 @@ func (r *organizationRepository) ListLimitRequestsForAdmin(ctx context.Context,
|
||||
}
|
||||
addBefore := func(col string, v *time.Time) {
|
||||
if v != nil {
|
||||
where += " AND " + col + " < ($" + itoa(argNum) + " + INTERVAL '1 day')"
|
||||
where += " AND " + col + " < ($" + itoa(argNum) + "::timestamptz + INTERVAL '1 day')"
|
||||
args = append(args, *v)
|
||||
argNum++
|
||||
}
|
||||
@@ -1338,12 +1332,7 @@ func (r *organizationRepository) ListLimitRequestsForAdmin(ctx context.Context,
|
||||
addAfter("lr.reviewed_at", search.ReviewedAfter)
|
||||
addBefore("lr.reviewed_at", search.ReviewedBefore)
|
||||
|
||||
// Keyset on id (mirrors the org explorer; default sort is submitted_at).
|
||||
if search.Cursor != nil {
|
||||
where += " AND lr.id < $" + itoa(argNum)
|
||||
args = append(args, *search.Cursor)
|
||||
argNum++
|
||||
}
|
||||
offset := search.Offset
|
||||
|
||||
orderCol := "lr.submitted_at"
|
||||
switch search.SortBy {
|
||||
@@ -1379,7 +1368,7 @@ func (r *organizationRepository) ListLimitRequestsForAdmin(ctx context.Context,
|
||||
JOIN users u ON u.id = lr.submitted_by
|
||||
` + where + `
|
||||
` + orderBy + `
|
||||
LIMIT $` + itoa(argNum)
|
||||
` + adminLimitOffset("$"+itoa(argNum), offset)
|
||||
|
||||
rows, err := r.db.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
@@ -1411,8 +1400,7 @@ func (r *organizationRepository) ListLimitRequestsForAdmin(ctx context.Context,
|
||||
}
|
||||
if len(items) > limit {
|
||||
result.Data = items[:limit]
|
||||
last := items[limit-1].ID
|
||||
result.Pagination.NextCursor = paging.UUIDString(last)
|
||||
result.Pagination.NextCursor = adminNextCursor(offset, limit)
|
||||
}
|
||||
|
||||
// Total count for the same filter — drop the trailing LIMIT arg.
|
||||
|
||||
@@ -122,7 +122,7 @@ func TestAdminWorkerQueriesLive(t *testing.T) {
|
||||
}
|
||||
|
||||
admin := NewAdminRepository(pool)
|
||||
res, err := admin.ListWorkers(ctx, nil, 5)
|
||||
res, err := admin.ListWorkers(ctx, 0, 5)
|
||||
if err != nil {
|
||||
t.Fatalf("admin ListWorkers: %v", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user