feat: pagina inbox e valida tokens do companion

This commit is contained in:
Felipe Coutinho
2026-03-20 18:40:13 +00:00
parent 3c31ee5d90
commit 29551ee02f
12 changed files with 451 additions and 185 deletions

View File

@@ -1,29 +1,55 @@
import { InboxPage } from "@/features/inbox/components/inbox-page";
import {
type ResolvedInboxSearchParams,
resolveInboxPagination,
resolveInboxStatus,
} from "@/features/inbox/page-helpers";
import {
fetchAppLogoMap,
fetchInboxDialogData,
fetchInboxItems,
fetchInboxItemsPage,
fetchInboxStatusCounts,
} from "@/features/inbox/queries";
import { getUserId } from "@/shared/lib/auth/server";
export default async function Page() {
const userId = await getUserId();
type PageSearchParams = Promise<ResolvedInboxSearchParams>;
const [pendingItems, processedItems, discardedItems, dialogData, appLogoMap] =
await Promise.all([
fetchInboxItems(userId, "pending"),
fetchInboxItems(userId, "processed"),
fetchInboxItems(userId, "discarded"),
fetchInboxDialogData(userId),
type PageProps = {
searchParams?: PageSearchParams;
};
const EMPTY_DIALOG_DATA = {
payerOptions: [],
splitPayerOptions: [],
defaultPayerId: null,
accountOptions: [],
cardOptions: [],
categoryOptions: [],
estabelecimentos: [],
};
export default async function Page({ searchParams }: PageProps) {
const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined;
const activeStatus = resolveInboxStatus(resolvedSearchParams);
const paginationInput = resolveInboxPagination(resolvedSearchParams);
const [itemsPage, counts, dialogData, appLogoMap] = await Promise.all([
fetchInboxItemsPage(userId, activeStatus, paginationInput),
fetchInboxStatusCounts(userId),
activeStatus === "pending"
? fetchInboxDialogData(userId)
: Promise.resolve(EMPTY_DIALOG_DATA),
fetchAppLogoMap(userId),
]);
return (
<main className="flex flex-col items-start gap-6">
<InboxPage
pendingItems={pendingItems}
processedItems={processedItems}
discardedItems={discardedItems}
activeStatus={activeStatus}
items={itemsPage.items}
counts={counts}
pagination={itemsPage.pagination}
payerOptions={dialogData.payerOptions}
splitPayerOptions={dialogData.splitPayerOptions}
defaultPayerId={dialogData.defaultPayerId}

View File

@@ -1,4 +1,4 @@
import { and, eq, isNull } from "drizzle-orm";
import { and, eq, gt, isNull } from "drizzle-orm";
import { NextResponse } from "next/server";
import { apiTokens } from "@/db/schema";
import {
@@ -38,6 +38,7 @@ export async function POST(request: Request) {
eq(apiTokens.id, payload.tokenId),
eq(apiTokens.userId, payload.sub),
isNull(apiTokens.revokedAt),
gt(apiTokens.expiresAt, new Date()),
),
});
@@ -65,8 +66,9 @@ export async function POST(request: Request) {
tokenHash: hashToken(result.accessToken),
lastUsedAt: new Date(),
lastUsedIp:
request.headers.get("x-forwarded-for") ||
request.headers.get("x-real-ip"),
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") ||
null,
expiresAt: result.expiresAt,
})
.where(eq(apiTokens.id, payload.tokenId));

View File

@@ -39,7 +39,9 @@ export async function DELETE(_request: Request, { params }: RouteParams) {
await db
.update(apiTokens)
.set({ revokedAt: new Date() })
.where(eq(apiTokens.id, tokenId));
.where(
and(eq(apiTokens.id, tokenId), eq(apiTokens.userId, session.user.id)),
);
return NextResponse.json({
message: "Token revogado com sucesso",

View File

@@ -1,4 +1,4 @@
import { and, eq, isNull } from "drizzle-orm";
import { and, eq, gt, isNull } from "drizzle-orm";
import { NextResponse } from "next/server";
import { apiTokens } from "@/db/schema";
import { extractBearerToken, hashToken } from "@/shared/lib/auth/api-token";
@@ -33,6 +33,7 @@ export async function POST(request: Request) {
where: and(
eq(apiTokens.tokenHash, tokenHash),
isNull(apiTokens.revokedAt),
gt(apiTokens.expiresAt, new Date()),
),
});

View File

@@ -1,4 +1,4 @@
import { and, eq, isNull } from "drizzle-orm";
import { and, eq, gt, isNull, or } from "drizzle-orm";
import { NextResponse } from "next/server";
import { z } from "zod";
import { apiTokens, inboxItems } from "@/db/schema";
@@ -63,6 +63,7 @@ export async function POST(request: Request) {
where: and(
eq(apiTokens.tokenHash, tokenHash),
isNull(apiTokens.revokedAt),
or(isNull(apiTokens.expiresAt), gt(apiTokens.expiresAt, new Date())),
),
});
@@ -111,10 +112,11 @@ export async function POST(request: Request) {
success: true,
});
} catch (error) {
console.error("[API] Error processing batch item:", error);
results.push({
clientId: item.clientId,
success: false,
error: error instanceof Error ? error.message : "Erro desconhecido",
error: "Erro ao processar notificação",
});
}
}

View File

@@ -1,4 +1,4 @@
import { and, eq, isNull } from "drizzle-orm";
import { and, eq, gt, isNull, or } from "drizzle-orm";
import { NextResponse } from "next/server";
import { z } from "zod";
import { apiTokens, inboxItems } from "@/db/schema";
@@ -56,6 +56,7 @@ export async function POST(request: Request) {
where: and(
eq(apiTokens.tokenHash, tokenHash),
isNull(apiTokens.revokedAt),
or(isNull(apiTokens.expiresAt), gt(apiTokens.expiresAt, new Date())),
),
});

View File

@@ -39,8 +39,8 @@ const bulkDeleteSelectedInboxSchema = z.object({
inboxItemIds: z.array(z.string().uuid()).min(1, "Selecione ao menos um item"),
});
function revalidateInbox() {
revalidateForEntity("inbox");
function revalidateInbox(userId: string) {
revalidateForEntity("inbox", userId);
}
/**
@@ -85,7 +85,7 @@ export async function markInboxAsProcessedAction(
),
);
revalidateInbox();
revalidateInbox(user.id);
return { success: true, message: "Item processado com sucesso!" };
} catch (error) {
@@ -132,7 +132,7 @@ export async function discardInboxItemAction(
),
);
revalidateInbox();
revalidateInbox(user.id);
return { success: true, message: "Item descartado." };
} catch (error) {
@@ -163,7 +163,7 @@ export async function bulkDiscardInboxItemsAction(
),
);
revalidateInbox();
revalidateInbox(user.id);
return {
success: true,
@@ -214,7 +214,7 @@ export async function restoreDiscardedInboxItemAction(
),
);
revalidateInbox();
revalidateInbox(user.id);
return { success: true, message: "Item voltou para pendentes." };
} catch (error) {
@@ -260,7 +260,7 @@ export async function deleteInboxItemAction(
),
);
revalidateInbox();
revalidateInbox(user.id);
return { success: true, message: "Item excluído." };
} catch (error) {
@@ -286,7 +286,7 @@ export async function bulkDeleteSelectedInboxItemsAction(
)
.returning({ id: inboxItems.id });
revalidateInbox();
revalidateInbox(user.id);
const count = result.length;
return {
@@ -312,7 +312,7 @@ export async function bulkDeleteInboxItemsAction(
)
.returning({ id: inboxItems.id });
revalidateInbox();
revalidateInbox(user.id);
const count = result.length;
return {

View File

@@ -1,7 +1,15 @@
"use client";
import { RiAtLine, RiDeleteBinLine } from "@remixicon/react";
import { useMemo, useState } from "react";
import {
RiArrowLeftDoubleLine,
RiArrowLeftSLine,
RiArrowRightDoubleLine,
RiArrowRightSLine,
RiAtLine,
RiDeleteBinLine,
} from "@remixicon/react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
import {
bulkDeleteInboxItemsAction,
@@ -12,11 +20,22 @@ import {
markInboxAsProcessedAction,
restoreDiscardedInboxItemAction,
} from "@/features/inbox/actions";
import {
INBOX_DEFAULT_PAGE_SIZE,
INBOX_PAGE_SIZE_OPTIONS,
} from "@/features/inbox/page-helpers";
import { TransactionDialog } from "@/features/transactions/components/dialogs/transaction-dialog/transaction-dialog";
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
import { EmptyState } from "@/shared/components/empty-state";
import { Button } from "@/shared/components/ui/button";
import { Card } from "@/shared/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select";
import {
Tabs,
TabsContent,
@@ -25,12 +44,19 @@ import {
} from "@/shared/components/ui/tabs";
import { InboxCard } from "./inbox-card";
import { InboxDetailsDialog } from "./inbox-details-dialog";
import type { InboxItem, SelectOption } from "./types";
import type {
InboxItem,
InboxPaginationState,
InboxStatus,
InboxStatusCounts,
SelectOption,
} from "./types";
interface InboxPageProps {
pendingItems: InboxItem[];
processedItems: InboxItem[];
discardedItems: InboxItem[];
activeStatus: InboxStatus;
items: InboxItem[];
counts: InboxStatusCounts;
pagination: InboxPaginationState;
payerOptions: SelectOption[];
splitPayerOptions: SelectOption[];
defaultPayerId: string | null;
@@ -42,9 +68,10 @@ interface InboxPageProps {
}
export function InboxPage({
pendingItems,
processedItems,
discardedItems,
activeStatus,
items,
counts,
pagination,
payerOptions,
splitPayerOptions,
defaultPayerId,
@@ -54,6 +81,10 @@ export function InboxPage({
estabelecimentos,
appLogoMap,
}: InboxPageProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition();
const [processOpen, setProcessOpen] = useState(false);
const [itemToProcess, setItemToProcess] = useState<InboxItem | null>(null);
@@ -74,46 +105,11 @@ export function InboxPage({
"processed" | "discarded"
>("processed");
const [selectedPendingIds, setSelectedPendingIds] = useState<string[]>([]);
const [selectedProcessedIds, setSelectedProcessedIds] = useState<string[]>(
[],
);
const [selectedDiscardedIds, setSelectedDiscardedIds] = useState<string[]>(
[],
);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [selectionBulkOpen, setSelectionBulkOpen] = useState(false);
const [selectionBulkStatus, setSelectionBulkStatus] = useState<
"pending" | "processed" | "discarded"
>("pending");
const sortedPending = useMemo(
() =>
[...pendingItems].sort(
(a, b) =>
new Date(b.notificationTimestamp).getTime() -
new Date(a.notificationTimestamp).getTime(),
),
[pendingItems],
);
const sortedProcessed = useMemo(
() =>
[...processedItems].sort(
(a, b) =>
new Date(b.notificationTimestamp).getTime() -
new Date(a.notificationTimestamp).getTime(),
),
[processedItems],
);
const sortedDiscarded = useMemo(
() =>
[...discardedItems].sort(
(a, b) =>
new Date(b.notificationTimestamp).getTime() -
new Date(a.notificationTimestamp).getTime(),
),
[discardedItems],
);
const [selectionBulkStatus, setSelectionBulkStatus] =
useState<InboxStatus>("pending");
const handleProcessOpenChange = (open: boolean) => {
setProcessOpen(open);
@@ -223,40 +219,72 @@ export function InboxPage({
throw new Error(result.error);
};
const toggleSelection = (
ids: string[],
setIds: (v: string[]) => void,
id: string,
useEffect(() => {
const visibleIds = new Set(items.map((item) => item.id));
setSelectedIds((current) => current.filter((id) => visibleIds.has(id)));
}, [items]);
const toggleSelection = (id: string) => {
setSelectedIds((current) =>
current.includes(id)
? current.filter((value) => value !== id)
: [...current, id],
);
};
const allSelected = items.length > 0 && selectedIds.length === items.length;
const toggleSelectAll = () => {
if (allSelected) {
setSelectedIds([]);
return;
}
setSelectedIds(items.map((item) => item.id));
};
const updateUrl = (
nextStatus: InboxStatus,
nextPage: number,
nextPageSize: number,
) => {
setIds(ids.includes(id) ? ids.filter((x) => x !== id) : [...ids, id]);
const nextParams = new URLSearchParams(searchParams.toString());
if (nextStatus === "pending") {
nextParams.delete("status");
} else {
nextParams.set("status", nextStatus);
}
if (nextPage <= 1) {
nextParams.delete("page");
} else {
nextParams.set("page", nextPage.toString());
}
if (nextPageSize === INBOX_DEFAULT_PAGE_SIZE) {
nextParams.delete("pageSize");
} else {
nextParams.set("pageSize", nextPageSize.toString());
}
startTransition(() => {
const target = nextParams.toString()
? `${pathname}?${nextParams.toString()}`
: pathname;
router.replace(target, { scroll: false });
});
};
const allPendingSelected =
sortedPending.length > 0 &&
selectedPendingIds.length === sortedPending.length;
const allProcessedSelected =
sortedProcessed.length > 0 &&
selectedProcessedIds.length === sortedProcessed.length;
const allDiscardedSelected =
sortedDiscarded.length > 0 &&
selectedDiscardedIds.length === sortedDiscarded.length;
const toggleSelectAllPending = () => {
if (allPendingSelected) setSelectedPendingIds([]);
else setSelectedPendingIds(sortedPending.map((item) => item.id));
};
const toggleSelectAllProcessed = () => {
if (allProcessedSelected) setSelectedProcessedIds([]);
else setSelectedProcessedIds(sortedProcessed.map((item) => item.id));
};
const toggleSelectAllDiscarded = () => {
if (allDiscardedSelected) setSelectedDiscardedIds([]);
else setSelectedDiscardedIds(sortedDiscarded.map((item) => item.id));
const handleTabChange = (nextStatus: string) => {
updateUrl(nextStatus as InboxStatus, 1, pagination.pageSize);
};
const handleSelectionBulkRequest = (
status: "pending" | "processed" | "discarded",
) => {
const handleSelectionBulkRequest = (status: InboxStatus) => {
if (selectedIds.length === 0) {
return;
}
setSelectionBulkStatus(status);
setSelectionBulkOpen(true);
};
@@ -264,27 +292,22 @@ export function InboxPage({
const handleSelectionBulkConfirm = async () => {
if (selectionBulkStatus === "pending") {
const result = await bulkDiscardInboxItemsAction({
inboxItemIds: selectedPendingIds,
inboxItemIds: selectedIds,
});
if (result.success) {
toast.success(result.message);
setSelectedPendingIds([]);
setSelectedIds([]);
return;
}
toast.error(result.error);
throw new Error(result.error);
} else {
const ids =
selectionBulkStatus === "processed"
? selectedProcessedIds
: selectedDiscardedIds;
const result = await bulkDeleteSelectedInboxItemsAction({
inboxItemIds: ids,
inboxItemIds: selectedIds,
});
if (result.success) {
toast.success(result.message);
if (selectionBulkStatus === "processed") setSelectedProcessedIds([]);
else setSelectedDiscardedIds([]);
setSelectedIds([]);
return;
}
toast.error(result.error);
@@ -329,6 +352,9 @@ export function InboxPage({
}
};
const canPreviousPage = pagination.page > 1;
const canNextPage = pagination.page < pagination.totalPages;
// Prepare default values from inbox item
const getDateString = (
date: Date | string | null | undefined,
@@ -375,12 +401,7 @@ export function InboxPage({
</Card>
);
const renderGrid = (
list: InboxItem[],
readonly?: boolean,
selectedIds?: string[],
onToggle?: (id: string) => void,
) =>
const renderGrid = (list: InboxItem[], readonly?: boolean) =>
list.length === 0 ? (
renderEmptyState(
readonly
@@ -400,8 +421,8 @@ export function InboxPage({
onViewDetails={readonly ? undefined : handleDetailsRequest}
onDelete={readonly ? handleDeleteRequest : undefined}
onRestoreToPending={readonly ? handleRestoreRequest : undefined}
selected={selectedIds?.includes(item.id)}
onSelectToggle={onToggle}
selected={selectedIds.includes(item.id)}
onSelectToggle={toggleSelection}
/>
))}
</div>
@@ -409,79 +430,72 @@ export function InboxPage({
return (
<>
<Tabs defaultValue="pending" className="w-full">
<Tabs
value={activeStatus}
onValueChange={handleTabChange}
className="w-full"
>
<TabsList className="grid h-auto w-full grid-cols-3 sm:inline-flex sm:h-9 sm:grid-cols-none">
<TabsTrigger
value="pending"
disabled={isPending}
className="h-11 min-w-0 flex-col gap-0 px-1 text-sm leading-tight sm:h-9 sm:flex-row sm:gap-1 sm:px-4"
>
<span>Pendentes</span>
<span>({pendingItems.length})</span>
<span>({counts.pending})</span>
</TabsTrigger>
<TabsTrigger
value="processed"
disabled={isPending}
className="h-11 min-w-0 flex-col gap-0 px-1 text-sm leading-tight sm:h-9 sm:flex-row sm:gap-1 sm:px-4"
>
<span>Processados</span>
<span>({processedItems.length})</span>
<span>({counts.processed})</span>
</TabsTrigger>
<TabsTrigger
value="discarded"
disabled={isPending}
className="h-11 min-w-0 flex-col gap-0 px-1 text-sm leading-tight sm:h-9 sm:flex-row sm:gap-1 sm:px-4"
>
<span>Descartados</span>
<span>({discardedItems.length})</span>
<span>({counts.discarded})</span>
</TabsTrigger>
</TabsList>
<TabsContent value="pending" className="mt-4">
{sortedPending.length > 0 && (
{activeStatus === "pending" && items.length > 0 && (
<div className="mb-4 flex items-center justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={toggleSelectAllPending}
>
{allPendingSelected
? "Desselecionar todos"
: "Selecionar todos"}
<Button variant="outline" size="sm" onClick={toggleSelectAll}>
{allSelected ? "Cancelar seleção" : "Selecionar página"}
</Button>
{selectedPendingIds.length > 0 && (
{selectedIds.length > 0 && (
<Button
variant="destructive"
size="sm"
onClick={() => handleSelectionBulkRequest("pending")}
>
<RiDeleteBinLine className="mr-1.5 size-4" />
Descartar selecionados ({selectedPendingIds.length})
Descartar selecionados ({selectedIds.length})
</Button>
)}
</div>
)}
{renderGrid(sortedPending, false, selectedPendingIds, (id) =>
toggleSelection(selectedPendingIds, setSelectedPendingIds, id),
)}
{activeStatus === "pending" ? renderGrid(items, false) : null}
</TabsContent>
<TabsContent value="processed" className="mt-4">
{sortedProcessed.length > 0 && (
{activeStatus === "processed" && items.length > 0 && (
<div className="mb-4 flex items-center justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={toggleSelectAllProcessed}
>
{allProcessedSelected
? "Desselecionar todos"
: "Selecionar todos"}
<Button variant="outline" size="sm" onClick={toggleSelectAll}>
{allSelected ? "Cancelar seleção" : "Selecionar página"}
</Button>
{selectedProcessedIds.length > 0 && (
{selectedIds.length > 0 && (
<Button
variant="destructive"
size="sm"
onClick={() => handleSelectionBulkRequest("processed")}
>
<RiDeleteBinLine className="mr-1.5 size-4" />
Excluir selecionados ({selectedProcessedIds.length})
Excluir selecionados ({selectedIds.length})
</Button>
)}
<Button
@@ -494,30 +508,22 @@ export function InboxPage({
</Button>
</div>
)}
{renderGrid(sortedProcessed, true, selectedProcessedIds, (id) =>
toggleSelection(selectedProcessedIds, setSelectedProcessedIds, id),
)}
{activeStatus === "processed" ? renderGrid(items, true) : null}
</TabsContent>
<TabsContent value="discarded" className="mt-4">
{sortedDiscarded.length > 0 && (
{activeStatus === "discarded" && items.length > 0 && (
<div className="mb-4 flex items-center justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={toggleSelectAllDiscarded}
>
{allDiscardedSelected
? "Desselecionar todos"
: "Selecionar todos"}
<Button variant="outline" size="sm" onClick={toggleSelectAll}>
{allSelected ? "Cancelar seleção" : "Selecionar página"}
</Button>
{selectedDiscardedIds.length > 0 && (
{selectedIds.length > 0 && (
<Button
variant="destructive"
size="sm"
onClick={() => handleSelectionBulkRequest("discarded")}
>
<RiDeleteBinLine className="mr-1.5 size-4" />
Excluir selecionados ({selectedDiscardedIds.length})
Excluir selecionados ({selectedIds.length})
</Button>
)}
<Button
@@ -530,12 +536,99 @@ export function InboxPage({
</Button>
</div>
)}
{renderGrid(sortedDiscarded, true, selectedDiscardedIds, (id) =>
toggleSelection(selectedDiscardedIds, setSelectedDiscardedIds, id),
)}
{activeStatus === "discarded" ? renderGrid(items, true) : null}
</TabsContent>
</Tabs>
{pagination.totalItems > 0 ? (
<div className="flex w-full flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
{pagination.totalItems} notificações
</span>
<Select
disabled={isPending}
value={pagination.pageSize.toString()}
onValueChange={(value) => {
updateUrl(activeStatus, 1, Number(value));
}}
>
<SelectTrigger className="w-max">
<SelectValue />
</SelectTrigger>
<SelectContent>
{INBOX_PAGE_SIZE_OPTIONS.map((option) => (
<SelectItem key={option} value={option.toString()}>
{option} itens
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
Página {pagination.page} de {pagination.totalPages}
</span>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon-sm"
onClick={() => updateUrl(activeStatus, 1, pagination.pageSize)}
disabled={!canPreviousPage || isPending}
aria-label="Primeira página"
>
<RiArrowLeftDoubleLine className="size-4" />
</Button>
<Button
variant="outline"
size="icon-sm"
onClick={() =>
updateUrl(
activeStatus,
pagination.page - 1,
pagination.pageSize,
)
}
disabled={!canPreviousPage || isPending}
aria-label="Página anterior"
>
<RiArrowLeftSLine className="size-4" />
</Button>
<Button
variant="outline"
size="icon-sm"
onClick={() =>
updateUrl(
activeStatus,
pagination.page + 1,
pagination.pageSize,
)
}
disabled={!canNextPage || isPending}
aria-label="Próxima página"
>
<RiArrowRightSLine className="size-4" />
</Button>
<Button
variant="outline"
size="icon-sm"
onClick={() =>
updateUrl(
activeStatus,
pagination.totalPages,
pagination.pageSize,
)
}
disabled={!canNextPage || isPending}
aria-label="Última página"
>
<RiArrowRightDoubleLine className="size-4" />
</Button>
</div>
</div>
</div>
) : null}
<TransactionDialog
mode="create"
open={processOpen}
@@ -617,8 +710,8 @@ export function InboxPage({
}
description={
selectionBulkStatus === "pending"
? `${selectedPendingIds.length} item(s) serão descartados.`
: `${selectionBulkStatus === "processed" ? selectedProcessedIds.length : selectedDiscardedIds.length} item(s) serão excluídos permanentemente.`
? `${selectedIds.length} item(s) serão descartados.`
: `${selectedIds.length} item(s) serão excluídos permanentemente.`
}
confirmLabel={
selectionBulkStatus === "pending" ? "Descartar" : "Excluir"

View File

@@ -1,5 +1,7 @@
import type { SelectOption as LancamentoSelectOption } from "@/features/transactions/components/types";
export type InboxStatus = "pending" | "processed" | "discarded";
export interface InboxItem {
id: string;
sourceApp: string;
@@ -17,5 +19,14 @@ export interface InboxItem {
updatedAt: Date;
}
export type InboxStatusCounts = Record<InboxStatus, number>;
export type InboxPaginationState = {
page: number;
pageSize: number;
totalItems: number;
totalPages: number;
};
// Re-export the lancamentos SelectOption for use in inbox components
export type SelectOption = LancamentoSelectOption;

View File

@@ -0,0 +1,49 @@
import type { InboxPaginationState, InboxStatus } from "./components/types";
export type ResolvedInboxSearchParams =
| Record<string, string | string[] | undefined>
| undefined;
export const INBOX_DEFAULT_PAGE_SIZE = 12;
export const INBOX_PAGE_SIZE_OPTIONS = [12, 24, 48];
export const INBOX_STATUSES = ["pending", "processed", "discarded"] as const;
export const getSingleParam = (
params: ResolvedInboxSearchParams,
key: string,
): string | null => {
const value = params?.[key];
if (!value) {
return null;
}
return Array.isArray(value) ? (value[0] ?? null) : value;
};
export const resolveInboxStatus = (
params: ResolvedInboxSearchParams,
): InboxStatus => {
const status = getSingleParam(params, "status");
return INBOX_STATUSES.includes(status as InboxStatus)
? (status as InboxStatus)
: "pending";
};
export const resolveInboxPagination = (
params: ResolvedInboxSearchParams,
): Pick<InboxPaginationState, "page" | "pageSize"> => {
const pageParam = Number.parseInt(getSingleParam(params, "page") ?? "", 10);
const pageSizeParam = Number.parseInt(
getSingleParam(params, "pageSize") ?? "",
10,
);
return {
page: Number.isFinite(pageParam) && pageParam > 0 ? pageParam : 1,
pageSize: INBOX_PAGE_SIZE_OPTIONS.includes(pageSizeParam)
? pageSizeParam
: INBOX_DEFAULT_PAGE_SIZE,
};
};

View File

@@ -1,7 +1,10 @@
import { and, desc, eq } from "drizzle-orm";
import { and, count, desc, eq } from "drizzle-orm";
import { cards, categories, financialAccounts, inboxItems } from "@/db/schema";
import type {
InboxItem,
InboxPaginationState,
InboxStatus,
InboxStatusCounts,
SelectOption,
} from "@/features/inbox/components/types";
import {
@@ -16,17 +19,90 @@ import { db } from "@/shared/lib/db";
export async function fetchInboxItems(
userId: string,
status: "pending" | "processed" | "discarded" = "pending",
status: InboxStatus = "pending",
): Promise<InboxItem[]> {
const items = await db
.select()
.from(inboxItems)
.where(and(eq(inboxItems.userId, userId), eq(inboxItems.status, status)))
.orderBy(desc(inboxItems.createdAt));
.orderBy(
desc(inboxItems.notificationTimestamp),
desc(inboxItems.createdAt),
);
return items;
}
export async function fetchInboxItemsPage(
userId: string,
status: InboxStatus,
{
page,
pageSize,
}: {
page: number;
pageSize: number;
},
): Promise<{
items: InboxItem[];
pagination: InboxPaginationState;
}> {
const [countRow] = await db
.select({ total: count() })
.from(inboxItems)
.where(and(eq(inboxItems.userId, userId), eq(inboxItems.status, status)));
const totalItems = Number(countRow?.total ?? 0);
const totalPages = Math.max(Math.ceil(totalItems / pageSize), 1);
const currentPage = Math.min(page, totalPages);
const offset = (currentPage - 1) * pageSize;
const items = await db
.select()
.from(inboxItems)
.where(and(eq(inboxItems.userId, userId), eq(inboxItems.status, status)))
.orderBy(desc(inboxItems.notificationTimestamp), desc(inboxItems.createdAt))
.limit(pageSize)
.offset(offset);
return {
items,
pagination: {
page: currentPage,
pageSize,
totalItems,
totalPages,
},
};
}
export async function fetchInboxStatusCounts(
userId: string,
): Promise<InboxStatusCounts> {
const rows = await db
.select({
status: inboxItems.status,
total: count(),
})
.from(inboxItems)
.where(eq(inboxItems.userId, userId))
.groupBy(inboxItems.status);
const counts: InboxStatusCounts = {
pending: 0,
processed: 0,
discarded: 0,
};
for (const row of rows) {
if (row.status in counts) {
counts[row.status as InboxStatus] = Number(row.total ?? 0);
}
}
return counts;
}
export async function fetchInboxItemById(
userId: string,
itemId: string,
@@ -112,14 +188,14 @@ export async function fetchAppLogoMap(
}
export async function fetchPendingInboxCount(userId: string): Promise<number> {
const items = await db
.select({ id: inboxItems.id })
const [result] = await db
.select({ total: count() })
.from(inboxItems)
.where(
and(eq(inboxItems.userId, userId), eq(inboxItems.status, "pending")),
);
return items.length;
return Number(result?.total ?? 0);
}
/**

View File

@@ -1,14 +1,17 @@
import { z } from "zod";
export const inboxItemSchema = z.object({
sourceApp: z.string().min(1, "sourceApp é obrigatório"),
sourceAppName: z.string().optional(),
originalTitle: z.string().optional(),
originalText: z.string().min(1, "originalText é obrigatório"),
notificationTimestamp: z.string().transform((val) => new Date(val)),
parsedName: z.string().optional(),
sourceApp: z.string().min(1, "sourceApp é obrigatório").max(255),
sourceAppName: z.string().max(255).optional(),
originalTitle: z.string().max(500).optional(),
originalText: z.string().min(1, "originalText é obrigatório").max(5000),
notificationTimestamp: z
.string()
.transform((val) => new Date(val))
.refine((d) => !Number.isNaN(d.getTime()), "Data de notificação inválida"),
parsedName: z.string().max(500).optional(),
parsedAmount: z.coerce.number().optional(),
clientId: z.string().optional(), // ID local do app para rastreamento
clientId: z.string().max(255).optional(), // ID local do app para rastreamento
});
export const inboxBatchSchema = z.object({