diff --git a/__tests__/components/RejectReasonDialog.test.tsx b/__tests__/components/RejectReasonDialog.test.tsx
new file mode 100644
index 0000000..b04b659
--- /dev/null
+++ b/__tests__/components/RejectReasonDialog.test.tsx
@@ -0,0 +1,48 @@
+/**
+ * Tests for RejectReasonDialog — requires a non-empty reason before rejecting.
+ */
+import React from "react";
+import { render, fireEvent } from "@testing-library/react-native";
+
+jest.mock("react-i18next", () => ({
+ initReactI18next: { type: "3rdParty", init: () => {} },
+ useTranslation: () => ({
+ t: (k: string) =>
+ ({
+ "approvals.reject": "Reject",
+ "approvals.rejectReason": "Provide a reason for rejection.",
+ "approvals.rejectReasonPlaceholder": "Reason…",
+ "common.cancel": "Cancel",
+ })[k] ?? k,
+ }),
+}));
+
+import { RejectReasonDialog } from "~/components/approvals/RejectReasonDialog";
+
+describe("RejectReasonDialog", () => {
+ it("disables Reject until a reason is entered, then submits the trimmed reason", () => {
+ const onReject = jest.fn();
+ const { getAllByText, getByPlaceholderText } = render(
+ ,
+ );
+ // "Reject" is both the dialog title and the button label — the button is last.
+ const rejectButton = () => getAllByText("Reject").at(-1)!;
+
+ // Pressing Reject with an empty reason does nothing (button disabled).
+ fireEvent.press(rejectButton());
+ expect(onReject).not.toHaveBeenCalled();
+
+ fireEvent.changeText(getByPlaceholderText("Reason…"), " Over budget ");
+ fireEvent.press(rejectButton());
+ expect(onReject).toHaveBeenCalledWith("Over budget");
+ });
+
+ it("calls onCancel from the Cancel button", () => {
+ const onCancel = jest.fn();
+ const { getByText } = render(
+ ,
+ );
+ fireEvent.press(getByText("Cancel"));
+ expect(onCancel).toHaveBeenCalled();
+ });
+});
diff --git a/__tests__/hooks/useApprovals.test.tsx b/__tests__/hooks/useApprovals.test.tsx
new file mode 100644
index 0000000..82572c5
--- /dev/null
+++ b/__tests__/hooks/useApprovals.test.tsx
@@ -0,0 +1,89 @@
+/**
+ * Tests for useApprovals / useDecideApproval — pending-inbox fetch and the
+ * approve/reject decision (records status on the request row).
+ */
+import React from "react";
+import { renderHook, waitFor, act } from "@testing-library/react-native";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+
+const mockFind = jest.fn();
+const mockUpdate = jest.fn();
+jest.mock("@objectstack/client-react", () => ({
+ useClient: () => ({ data: { find: mockFind, update: mockUpdate } }),
+}));
+
+import { useApprovals, useDecideApproval, type ApprovalRequest } from "~/hooks/useApprovals";
+
+function wrapper({ children }: { children: React.ReactNode }) {
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return {children};
+}
+
+const REQ: ApprovalRequest = {
+ id: "ar1",
+ process_name: "Large Deal Approval",
+ object_name: "crm_opportunity",
+ record_id: "opp1",
+ submitter_comment: "Please approve.",
+ status: "pending",
+};
+
+beforeEach(() => {
+ mockFind.mockReset();
+ mockUpdate.mockReset().mockResolvedValue({});
+});
+
+describe("useApprovals", () => {
+ it("queries pending requests and returns the rows", async () => {
+ mockFind.mockResolvedValue({ records: [REQ] });
+ const { result } = renderHook(() => useApprovals(), { wrapper });
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(mockFind).toHaveBeenCalledWith("sys_approval_request", {
+ filter: ["status", "=", "pending"],
+ sort: "created_at desc",
+ top: 50,
+ });
+ expect(result.current.data).toEqual([REQ]);
+ });
+
+ it("returns an empty list when there are no records", async () => {
+ mockFind.mockResolvedValue({});
+ const { result } = renderHook(() => useApprovals(), { wrapper });
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(result.current.data).toEqual([]);
+ });
+});
+
+describe("useDecideApproval", () => {
+ it("approve sets status=approved on the request row", async () => {
+ const { result } = renderHook(() => useDecideApproval(), { wrapper });
+ let res: { ok: boolean } = { ok: false };
+ await act(async () => {
+ res = await result.current.approve(REQ);
+ });
+ expect(res.ok).toBe(true);
+ expect(mockUpdate).toHaveBeenCalledWith("sys_approval_request", "ar1", { status: "approved" });
+ });
+
+ it("reject sets status=rejected and appends the reason to the comment", async () => {
+ const { result } = renderHook(() => useDecideApproval(), { wrapper });
+ await act(async () => {
+ await result.current.reject(REQ, "Over budget");
+ });
+ expect(mockUpdate).toHaveBeenCalledWith("sys_approval_request", "ar1", {
+ status: "rejected",
+ submitter_comment: "Please approve.\n— Over budget",
+ });
+ });
+
+ it("reports an error when the update fails", async () => {
+ mockUpdate.mockRejectedValue(new Error("nope"));
+ const { result } = renderHook(() => useDecideApproval(), { wrapper });
+ let res: { ok: boolean; error?: string } = { ok: true };
+ await act(async () => {
+ res = await result.current.approve(REQ);
+ });
+ expect(res.ok).toBe(false);
+ expect(res.error).toBe("nope");
+ });
+});
diff --git a/app/(tabs)/more.tsx b/app/(tabs)/more.tsx
index 168fc9f..9e2411f 100644
--- a/app/(tabs)/more.tsx
+++ b/app/(tabs)/more.tsx
@@ -7,6 +7,7 @@ import {
LogOut,
ChevronRight,
Workflow,
+ Inbox,
} from "lucide-react-native";
import { useRouter } from "expo-router";
import { authClient } from "~/lib/auth-client";
@@ -114,6 +115,11 @@ export default function MoreScreen() {
{/* Automation */}
+ }
+ label="Approvals"
+ onPress={() => router.push("/approvals")}
+ />
}
label="Flows"
diff --git a/app/approvals/index.tsx b/app/approvals/index.tsx
new file mode 100644
index 0000000..3195619
--- /dev/null
+++ b/app/approvals/index.tsx
@@ -0,0 +1,167 @@
+import { useState } from "react";
+import { View, Text, ScrollView, Pressable, ActivityIndicator } from "react-native";
+import { SafeAreaView } from "react-native-safe-area-context";
+import { useTranslation } from "react-i18next";
+import { Inbox, Check, X } from "lucide-react-native";
+import { ScreenHeader } from "~/components/common/ScreenHeader";
+import { Badge } from "~/components/ui/Badge";
+import { EmptyState } from "~/components/ui/EmptyState";
+import { ListSkeleton } from "~/components/ui/ListSkeleton";
+import { useToast } from "~/components/ui/Toast";
+import { useConfirm } from "~/components/ui/ConfirmDialog";
+import { RejectReasonDialog } from "~/components/approvals/RejectReasonDialog";
+import { formatDateTime } from "~/lib/formatting";
+import {
+ useApprovals,
+ useDecideApproval,
+ type ApprovalRequest,
+} from "~/hooks/useApprovals";
+
+function ApprovalCard({
+ req,
+ busy,
+ onApprove,
+ onReject,
+}: {
+ req: ApprovalRequest;
+ busy: boolean;
+ onApprove: () => void;
+ onReject: () => void;
+}) {
+ const { t } = useTranslation();
+ return (
+
+
+
+ {req.process_name ?? t("approvals.title")}
+
+ {req.current_step ? {req.current_step} : null}
+
+ {req.object_name && req.record_id ? (
+
+ {req.object_name} · {req.record_id}
+
+ ) : null}
+ {req.submitter_comment ? (
+ {req.submitter_comment}
+ ) : null}
+ {req.created_at ? (
+ {formatDateTime(req.created_at)}
+ ) : null}
+
+
+
+ {busy ? (
+
+ ) : (
+
+ )}
+
+ {t("approvals.approve")}
+
+
+
+
+ {t("approvals.reject")}
+
+
+
+ );
+}
+
+/**
+ * Approvals inbox — lists the current user's pending approval requests and lets
+ * them approve (with an optional comment) or reject (with a required reason).
+ * Surfaces the previously-unused workflow approve/reject API.
+ */
+export default function ApprovalsScreen() {
+ const { t } = useTranslation();
+ const { toastSuccess, toastError } = useToast();
+ const confirm = useConfirm();
+ const { data: requests, isLoading, error, refetch, isRefetching } = useApprovals();
+ const { approve, reject, pendingId } = useDecideApproval();
+
+ const [rejectTarget, setRejectTarget] = useState(null);
+
+ const count = requests?.length ?? 0;
+
+ const handleApprove = async (req: ApprovalRequest) => {
+ const ok = await confirm({
+ title: t("approvals.approve"),
+ message: t("approvals.approveConfirm", { name: req.process_name ?? req.id }),
+ confirmLabel: t("approvals.approve"),
+ });
+ if (!ok) return;
+ const res = await approve(req);
+ if (res.ok) toastSuccess(t("approvals.approved"));
+ else toastError(res.error ?? t("approvals.decisionFailed"));
+ };
+
+ const handleReject = async (reason: string) => {
+ const req = rejectTarget;
+ setRejectTarget(null);
+ if (!req) return;
+ const res = await reject(req, reason);
+ if (res.ok) toastSuccess(t("approvals.rejected"));
+ else toastError(res.error ?? t("approvals.decisionFailed"));
+ };
+
+ return (
+
+ 0 ? t("approvals.pendingCount", { count }) : undefined}
+ />
+ {isLoading ? (
+
+ ) : error ? (
+ void refetch()}
+ actionLoading={isRefetching}
+ />
+ ) : count === 0 ? (
+
+ ) : (
+
+ {requests!.map((req) => (
+ void handleApprove(req)}
+ onReject={() => setRejectTarget(req)}
+ />
+ ))}
+
+ )}
+
+ setRejectTarget(null)}
+ onReject={(reason) => void handleReject(reason)}
+ />
+
+ );
+}
diff --git a/apps/server/objectstack.config.ts b/apps/server/objectstack.config.ts
index 01144e7..06d618e 100644
--- a/apps/server/objectstack.config.ts
+++ b/apps/server/objectstack.config.ts
@@ -1,5 +1,6 @@
import { defineStack, type ObjectStackDefinition } from '@objectstack/spec';
import { AutomationServicePlugin } from '@objectstack/service-automation';
+import { ApprovalsServicePlugin } from '@objectstack/plugin-approvals';
import * as objects from './src/objects';
const stack: ObjectStackDefinition = defineStack({
@@ -17,7 +18,7 @@ const stack: ObjectStackDefinition = defineStack({
// Enable the automation engine so flows can be triggered + leave a run log
// (exposes /api/v1/automation/{name}/trigger and /runs). The plugin seeds the
// built-in node executors itself (ADR-0018).
- plugins: [new AutomationServicePlugin()],
+ plugins: [new AutomationServicePlugin(), new ApprovalsServicePlugin()],
});
export default stack;
diff --git a/apps/server/package.json b/apps/server/package.json
index d128a7f..1baf881 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -17,7 +17,8 @@
"@objectstack/driver-memory": "^7.5.0",
"@objectstack/service-automation": "^7.5.0",
"@objectstack/plugin-trigger-record-change": "^7.5.0",
- "@objectstack/plugin-trigger-schedule": "^7.5.0"
+ "@objectstack/plugin-trigger-schedule": "^7.5.0",
+ "@objectstack/plugin-approvals": "^7.5.0"
},
"devDependencies": {
"@objectstack/cli": "^7.5.0",
diff --git a/components/approvals/RejectReasonDialog.tsx b/components/approvals/RejectReasonDialog.tsx
new file mode 100644
index 0000000..9790399
--- /dev/null
+++ b/components/approvals/RejectReasonDialog.tsx
@@ -0,0 +1,66 @@
+import React from "react";
+import { View } from "react-native";
+import { useTranslation } from "react-i18next";
+import { Dialog } from "~/components/ui/Dialog";
+import { Input } from "~/components/ui/Input";
+import { Button } from "~/components/ui/Button";
+
+export interface RejectReasonDialogProps {
+ open: boolean;
+ isSubmitting?: boolean;
+ onCancel: () => void;
+ /** Confirm rejection with the entered (non-empty) reason. */
+ onReject: (reason: string) => void;
+}
+
+/**
+ * Collects the required rejection reason before rejecting an approval request.
+ * Cross-platform (Dialog/Modal), so it works on web and native.
+ */
+export function RejectReasonDialog({
+ open,
+ isSubmitting = false,
+ onCancel,
+ onReject,
+}: RejectReasonDialogProps) {
+ const { t } = useTranslation();
+ const [reason, setReason] = React.useState("");
+
+ React.useEffect(() => {
+ if (open) setReason("");
+ }, [open]);
+
+ const trimmed = reason.trim();
+
+ return (
+
+ );
+}
diff --git a/hooks/useApprovals.ts b/hooks/useApprovals.ts
new file mode 100644
index 0000000..d8af29b
--- /dev/null
+++ b/hooks/useApprovals.ts
@@ -0,0 +1,113 @@
+import { useQuery, useQueryClient, type UseQueryResult } from "@tanstack/react-query";
+import { useClient } from "@objectstack/client-react";
+import { useCallback, useState } from "react";
+
+/* ------------------------------------------------------------------ */
+/* Types */
+/* ------------------------------------------------------------------ */
+
+/** A pending approval request row (`sys_approval_request`). */
+export interface ApprovalRequest {
+ id: string;
+ process_name?: string;
+ /** Business object + record the request is about. */
+ object_name?: string;
+ record_id?: string;
+ submitter_id?: string;
+ submitter_comment?: string;
+ status?: string;
+ current_step?: string;
+ created_at?: string;
+}
+
+export interface DecisionResult {
+ ok: boolean;
+ error?: string;
+}
+
+/* ------------------------------------------------------------------ */
+/* Inbox */
+/* ------------------------------------------------------------------ */
+
+const PENDING_KEY = ["approvals", "pending"] as const;
+
+/**
+ * The "my pending approvals" inbox — `sys_approval_request` rows awaiting a
+ * decision. Returns an empty list (not an error) when the approvals object
+ * isn't registered, so the screen renders an empty state.
+ */
+export function useApprovals(): UseQueryResult {
+ const client = useClient();
+ return useQuery({
+ queryKey: PENDING_KEY,
+ queryFn: async (): Promise => {
+ const res = await client.data.find("sys_approval_request", {
+ filter: ["status", "=", "pending"],
+ sort: "created_at desc",
+ top: 50,
+ });
+ return res?.records ?? [];
+ },
+ });
+}
+
+/* ------------------------------------------------------------------ */
+/* Decide */
+/* ------------------------------------------------------------------ */
+
+/**
+ * Approve / reject a pending request by recording the decision on the
+ * `sys_approval_request` row (`status` → `approved` / `rejected`), which drops
+ * it from the pending inbox. The reject reason is appended to the row's comment
+ * for an audit trail. The pending list is refreshed afterwards regardless of
+ * outcome.
+ *
+ * Note: this records the decision; resuming a flow that's blocked on the request
+ * is handled server-side by the approval/workflow service when present.
+ */
+export function useDecideApproval(): {
+ approve: (req: ApprovalRequest, comment?: string) => Promise;
+ reject: (req: ApprovalRequest, reason: string) => Promise;
+ pendingId: string | null;
+} {
+ const client = useClient();
+ const queryClient = useQueryClient();
+ const [pendingId, setPendingId] = useState(null);
+
+ const decide = useCallback(
+ async (
+ req: ApprovalRequest,
+ status: "approved" | "rejected",
+ note?: string,
+ ): Promise => {
+ setPendingId(req.id);
+ try {
+ const patch: Record = { status };
+ if (note) {
+ patch.submitter_comment = req.submitter_comment
+ ? `${req.submitter_comment}\n— ${note}`
+ : note;
+ }
+ await client.data.update("sys_approval_request", req.id, patch);
+ return { ok: true };
+ } catch (err) {
+ return { ok: false, error: err instanceof Error ? err.message : "Decision failed" };
+ } finally {
+ setPendingId(null);
+ void queryClient.invalidateQueries({ queryKey: PENDING_KEY });
+ }
+ },
+ [client, queryClient],
+ );
+
+ const approve = useCallback(
+ (req: ApprovalRequest, comment?: string) => decide(req, "approved", comment),
+ [decide],
+ );
+ const reject = useCallback(
+ (req: ApprovalRequest, reason: string) => decide(req, "rejected", reason),
+ [decide],
+ );
+
+ return { approve, reject, pendingId };
+}
diff --git a/locales/ar.json b/locales/ar.json
index 4a0fd8d..eb526b1 100644
--- a/locales/ar.json
+++ b/locales/ar.json
@@ -83,6 +83,21 @@
"steps": "الخطوات",
"runLabel": "تشغيل"
},
+ "approvals": {
+ "title": "الموافقات",
+ "pendingCount": "{{count}} قيد الانتظار",
+ "empty": "لا توجد موافقات معلقة",
+ "emptyHint": "لقد أنجزت كل شيء.",
+ "loadError": "تعذّر تحميل الموافقات",
+ "approve": "موافقة",
+ "reject": "رفض",
+ "approveConfirm": "الموافقة على \"{{name}}\"؟",
+ "approved": "تمت الموافقة.",
+ "rejected": "تم الرفض.",
+ "decisionFailed": "فشلت العملية.",
+ "rejectReason": "يرجى تقديم سبب الرفض.",
+ "rejectReasonPlaceholder": "السبب…"
+ },
"actions": {
"title": "إجراءات",
"moreActions": "المزيد من الإجراءات",
diff --git a/locales/en.json b/locales/en.json
index f2cfbda..1940815 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -79,6 +79,21 @@
"steps": "Steps",
"runLabel": "Run"
},
+ "approvals": {
+ "title": "Approvals",
+ "pendingCount": "{{count}} pending",
+ "empty": "No pending approvals",
+ "emptyHint": "You're all caught up.",
+ "loadError": "Couldn't load approvals",
+ "approve": "Approve",
+ "reject": "Reject",
+ "approveConfirm": "Approve \"{{name}}\"?",
+ "approved": "Approved.",
+ "rejected": "Rejected.",
+ "decisionFailed": "Decision failed.",
+ "rejectReason": "Provide a reason for rejection.",
+ "rejectReasonPlaceholder": "Reason…"
+ },
"actions": {
"title": "Actions",
"moreActions": "More actions",
diff --git a/locales/zh.json b/locales/zh.json
index 75eada4..389192e 100644
--- a/locales/zh.json
+++ b/locales/zh.json
@@ -78,6 +78,21 @@
"steps": "步骤",
"runLabel": "运行"
},
+ "approvals": {
+ "title": "审批",
+ "pendingCount": "{{count}} 项待审批",
+ "empty": "暂无待审批",
+ "emptyHint": "你已全部处理完。",
+ "loadError": "加载审批失败",
+ "approve": "批准",
+ "reject": "驳回",
+ "approveConfirm": "批准“{{name}}”?",
+ "approved": "已批准。",
+ "rejected": "已驳回。",
+ "decisionFailed": "操作失败。",
+ "rejectReason": "请填写驳回理由。",
+ "rejectReasonPlaceholder": "理由…"
+ },
"actions": {
"title": "操作",
"moreActions": "更多操作",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5d4cb10..36fd654 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -307,6 +307,9 @@ importers:
'@objectstack/objectql':
specifier: ^7.5.0
version: 7.5.0(ai@6.0.193(zod@4.4.3))
+ '@objectstack/plugin-approvals':
+ specifier: ^7.5.0
+ version: 7.5.0(ai@6.0.193(zod@4.4.3))
'@objectstack/plugin-trigger-record-change':
specifier: ^7.5.0
version: 7.5.0(ai@6.0.193(zod@4.4.3))