feat: update client data
This commit is contained in:
11
.vscode/settings.json
vendored
11
.vscode/settings.json
vendored
@@ -8,6 +8,15 @@
|
||||
"source.organizeImports.biome": "explicit",
|
||||
"source.fixAll.biome": "explicit"
|
||||
},
|
||||
"files.readonlyInclude": {
|
||||
"**/routeTree.gen.ts": true
|
||||
},
|
||||
"files.watcherExclude": {
|
||||
"**/routeTree.gen.ts": true
|
||||
},
|
||||
"search.exclude": {
|
||||
"**/routeTree.gen.ts": true
|
||||
},
|
||||
"files.exclude": {
|
||||
"**/.git": true,
|
||||
"**/.svn": true,
|
||||
@@ -15,5 +24,5 @@
|
||||
"**/CVS": true,
|
||||
"**/.DS_Store": true,
|
||||
"**/Thumbs.db": true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"dependencies": {
|
||||
"@module/payment": "workspace:*",
|
||||
"@platform/config": "workspace:*",
|
||||
"zod": "4.1.13"
|
||||
"zod": "4.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"@tailwindcss/vite": "4.1.17",
|
||||
"@tanstack/react-router": "1.139.9",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@valkyr/db": "npm:@jsr/valkyr__db@2.0.0",
|
||||
"@valkyr/db": "npm:@jsr/valkyr__db@3.0.1",
|
||||
"@zitadel/react": "1.1.0",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
@@ -48,10 +48,12 @@
|
||||
"tailwind-merge": "3.4.0",
|
||||
"tailwindcss": "4.1.17",
|
||||
"vaul": "1.1.2",
|
||||
"zod": "4.1.13"
|
||||
"zod": "4.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9.39.1",
|
||||
"@tanstack/react-router-devtools": "1.144.0",
|
||||
"@tanstack/router-plugin": "1.145.2",
|
||||
"@types/node": "24.10.1",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/react-dom": "19.2.3",
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { zitadel } from "../../services/zitadel.ts";
|
||||
|
||||
export function CallbackView() {
|
||||
const navigate = useNavigate();
|
||||
useEffect(() => {
|
||||
async function handleCallback() {
|
||||
try {
|
||||
const user = await zitadel.userManager.signinRedirectCallback();
|
||||
if (user) {
|
||||
navigate({ to: "/", replace: true });
|
||||
} else {
|
||||
navigate({ to: "/", replace: true });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Callback error", error);
|
||||
navigate({ to: "/", replace: true });
|
||||
}
|
||||
}
|
||||
handleCallback();
|
||||
}, [navigate]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { GalleryVerticalEnd } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Field, FieldDescription, FieldGroup, FieldSeparator } from "@/components/ui/field";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { zitadel } from "@/services/zitadel.ts";
|
||||
|
||||
export function LoginForm({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
zitadel.authorize();
|
||||
}}
|
||||
>
|
||||
<FieldGroup>
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<a href="#" className="flex flex-col items-center gap-2 font-medium">
|
||||
<div className="flex size-8 items-center justify-center rounded-md">
|
||||
<GalleryVerticalEnd className="size-6" />
|
||||
</div>
|
||||
<span className="sr-only">Valkyr Sandbox</span>
|
||||
</a>
|
||||
<h1 className="text-xl font-bold">Welcome to Valkyr Sandbox</h1>
|
||||
<FieldDescription>
|
||||
Don't have an account? <a href="#">Sign up</a>
|
||||
</FieldDescription>
|
||||
</div>
|
||||
<Field>
|
||||
<Button type="submit">Login with Zitadel</Button>
|
||||
</Field>
|
||||
<FieldSeparator>Or</FieldSeparator>
|
||||
<Field className="grid gap-4 sm:grid-cols-2">
|
||||
<Button variant="outline" type="button">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
Continue with Apple
|
||||
</Button>
|
||||
<Button variant="outline" type="button">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
Continue with Google
|
||||
</Button>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
<FieldDescription className="px-6 text-center">
|
||||
By clicking continue, you agree to our <a href="#">Terms of Service</a> and <a href="#">Privacy Policy</a>.
|
||||
</FieldDescription>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { LoginForm } from "./components/login-form.tsx";
|
||||
|
||||
export function LoginView() {
|
||||
return (
|
||||
<div className="bg-background flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
|
||||
<div className="w-full max-w-sm">
|
||||
<LoginForm />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import { Controller } from "@/lib/controller.tsx";
|
||||
|
||||
export class DashboardController extends Controller {}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { makeControllerComponent } from "@/lib/controller.tsx";
|
||||
|
||||
import { DashboardController } from "./dashboard.controller.ts";
|
||||
|
||||
export const DashboardView = makeControllerComponent(DashboardController, () => {
|
||||
return <div>Dashboard</div>;
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { GalleryVerticalEnd } from "lucide-react";
|
||||
|
||||
import { Controller } from "@/lib/controller.tsx";
|
||||
import { User } from "@/services/user.ts";
|
||||
import { auth } from "@/services/auth.ts";
|
||||
|
||||
type Tenant = {
|
||||
name: string;
|
||||
@@ -13,7 +13,7 @@ export class AppSiderbarController extends Controller<{
|
||||
tenant: Tenant;
|
||||
}> {
|
||||
async onInit() {
|
||||
const user = await User.resolve();
|
||||
const user = auth.user;
|
||||
if (user === undefined) {
|
||||
return {
|
||||
tenant: {
|
||||
|
||||
@@ -12,20 +12,25 @@ import {
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { LoadingSwap } from "@/components/ui/loading-swap";
|
||||
import { SidebarMenuSubButton } from "@/components/ui/sidebar.tsx";
|
||||
|
||||
import { makeControllerComponent } from "../../lib/controller.tsx";
|
||||
import { CreateBeneficiaryController } from "./create-beneficiary.controller.ts";
|
||||
import { FieldDescription, FieldGroup, FieldLabel } from "../ui/field.tsx";
|
||||
import { CreateBeneficiaryController } from "./create.controller.ts";
|
||||
|
||||
export const DialogCreateBeneficiary = makeControllerComponent(
|
||||
CreateBeneficiaryController,
|
||||
({ title, setLabel, submit, isSubmitting, ...props }) => {
|
||||
({ title, label, setLabel, submit, isSubmitting, ...props }) => {
|
||||
const labelId = useId();
|
||||
return (
|
||||
<Dialog>
|
||||
<form>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<SidebarMenuSubButton className="cursor-pointer bg-secondary hover:bg-secondary/70">
|
||||
<props.icon /> <span>{title}</span>
|
||||
@@ -33,20 +38,25 @@ export const DialogCreateBeneficiary = makeControllerComponent(
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create beneficiary</DialogTitle>
|
||||
<DialogTitle>Create Beneficiary</DialogTitle>
|
||||
<DialogDescription>Create a payment ledger and its supported currencies</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor={labelId}>Label</Label>
|
||||
<Input id={labelId} name="label" onChange={({ target: { value } }) => setLabel(value)} />
|
||||
</div>
|
||||
</div>
|
||||
<FieldGroup>
|
||||
<FieldLabel htmlFor={labelId}>Label</FieldLabel>
|
||||
<Input
|
||||
id={labelId}
|
||||
name="label"
|
||||
value={label}
|
||||
onChange={({ target: { value } }) => setLabel(value)}
|
||||
required
|
||||
/>
|
||||
<FieldDescription>Enter the identifying label for the Beneficiary</FieldDescription>
|
||||
</FieldGroup>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit" onClick={submit}>
|
||||
<Button type="submit">
|
||||
<LoadingSwap isLoading={isSubmitting}>Create</LoadingSwap>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
@@ -3,32 +3,32 @@ import type { LucideIcon } from "lucide-react";
|
||||
import { payment } from "@/database/payment.ts";
|
||||
import { Controller } from "@/lib/controller.tsx";
|
||||
import { api, getSuccessResponse } from "@/services/api.ts";
|
||||
import { User } from "@/services/user.ts";
|
||||
import { auth } from "@/services/auth.ts";
|
||||
|
||||
export class CreateBeneficiaryController extends Controller<
|
||||
{
|
||||
isSubmitting: boolean;
|
||||
label: string;
|
||||
},
|
||||
{ title: string; icon: LucideIcon }
|
||||
> {
|
||||
#label?: string;
|
||||
|
||||
async onInit() {
|
||||
return {
|
||||
isSubmitting: false,
|
||||
label: "",
|
||||
};
|
||||
}
|
||||
|
||||
setLabel(label: string) {
|
||||
this.#label = label;
|
||||
this.setState("label", label);
|
||||
}
|
||||
|
||||
async submit() {
|
||||
this.setState("isSubmitting", true);
|
||||
const res = await api.payment.benficiaries.create({
|
||||
body: {
|
||||
tenantId: await User.getTenantId(),
|
||||
label: this.#label,
|
||||
tenantId: auth.user.tenant.id,
|
||||
label: this.state.label,
|
||||
},
|
||||
});
|
||||
if ("data" in res) {
|
||||
@@ -36,6 +36,9 @@ export class CreateBeneficiaryController extends Controller<
|
||||
.collection("beneficiary")
|
||||
.insertOne(await getSuccessResponse(api.payment.benficiaries.id({ params: { id: res.data } })));
|
||||
}
|
||||
this.setState("isSubmitting", false);
|
||||
this.setState({
|
||||
isSubmitting: false,
|
||||
label: "",
|
||||
});
|
||||
}
|
||||
}
|
||||
15
app/src/components/beneficiary/read.component.tsx
Normal file
15
app/src/components/beneficiary/read.component.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { makeControllerComponent } from "@/lib/controller.tsx";
|
||||
|
||||
import { ReadBeneficiaryController } from "./read.controller.ts";
|
||||
|
||||
export const BeneficiaryComponent = makeControllerComponent(ReadBeneficiaryController, ({ beneficiary }) => {
|
||||
if (beneficiary === undefined) {
|
||||
return (
|
||||
<div className="w-full flex flex-col pt-20 items-center justify-center text-center">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Beneficiary Not Found</h1>
|
||||
<p className="text-muted-foreground mt-2 max-w-sm">Beneficiary with id "xxx" cannot be found.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <pre>{JSON.stringify(beneficiary, null, 2)}</pre>;
|
||||
});
|
||||
37
app/src/components/beneficiary/read.controller.ts
Normal file
37
app/src/components/beneficiary/read.controller.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { Beneficiary } from "@module/payment/client";
|
||||
|
||||
import { payment } from "@/database/payment.ts";
|
||||
import { Controller } from "@/lib/controller.tsx";
|
||||
import { auth } from "@/services/auth.ts";
|
||||
|
||||
export class ReadBeneficiaryController extends Controller<
|
||||
{
|
||||
beneficiary?: Beneficiary;
|
||||
},
|
||||
{
|
||||
id: string;
|
||||
}
|
||||
> {
|
||||
#subscription?: any;
|
||||
|
||||
async onInit() {
|
||||
await this.#subscribe(this.props.id);
|
||||
}
|
||||
|
||||
async onResolve() {
|
||||
await this.#subscribe(this.props.id);
|
||||
}
|
||||
|
||||
async onDestroy(): Promise<void> {
|
||||
this.#subscription?.unsubscribe();
|
||||
}
|
||||
|
||||
async #subscribe(_id: string) {
|
||||
this.#subscription?.unsubscribe();
|
||||
this.#subscription = await payment
|
||||
.collection("beneficiary")
|
||||
.subscribe({ _id, tenantId: auth.user.tenant.id }, { limit: 1 }, (beneficiary) => {
|
||||
this.setState("beneficiary", beneficiary);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,10 @@ import type { Beneficiary } from "@module/payment/client";
|
||||
import { Book, CirclePlusIcon, type LucideIcon, SquareTerminal } from "lucide-react";
|
||||
import type { FunctionComponent } from "react";
|
||||
|
||||
import { DialogCreateBeneficiary } from "@/components/beneficiary/create.component.tsx";
|
||||
import { loadBeneficiaries, payment } from "@/database/payment.ts";
|
||||
import { Controller } from "@/lib/controller.tsx";
|
||||
import { User } from "@/services/user.ts";
|
||||
|
||||
import { DialogCreateBeneficiary } from "./payment/create-beneficiary.tsx";
|
||||
import { auth } from "@/services/auth.ts";
|
||||
|
||||
export class NavPaymentController extends Controller<{
|
||||
items: MenuItem[];
|
||||
@@ -19,7 +18,7 @@ export class NavPaymentController extends Controller<{
|
||||
items: [
|
||||
{
|
||||
title: "Dashboard",
|
||||
url: "/payment",
|
||||
url: "/",
|
||||
icon: SquareTerminal,
|
||||
},
|
||||
],
|
||||
@@ -31,7 +30,7 @@ export class NavPaymentController extends Controller<{
|
||||
this.#subscriptions.push(
|
||||
await payment
|
||||
.collection("beneficiary")
|
||||
.subscribe({ tenantId: await User.getTenantId() }, { sort: { label: 1 } }, (beneficiaries) => {
|
||||
.subscribe({ tenantId: auth.user.tenant.id }, { sort: { label: 1 } }, (beneficiaries) => {
|
||||
this.setState("items", this.#getMenuItems(beneficiaries));
|
||||
}),
|
||||
);
|
||||
@@ -41,7 +40,7 @@ export class NavPaymentController extends Controller<{
|
||||
return [
|
||||
{
|
||||
title: "Dashboard",
|
||||
url: "/payment",
|
||||
url: "/",
|
||||
icon: SquareTerminal,
|
||||
},
|
||||
{
|
||||
@@ -57,7 +56,7 @@ export class NavPaymentController extends Controller<{
|
||||
},
|
||||
...beneficiaries.map((beneficiary) => ({
|
||||
title: beneficiary.label ?? "Unlabeled",
|
||||
url: `/payment/${beneficiary._id}`,
|
||||
url: `/beneficiaries/${beneficiary._id}`,
|
||||
})),
|
||||
],
|
||||
},
|
||||
|
||||
@@ -39,7 +39,7 @@ function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
<div
|
||||
data-slot="field-group"
|
||||
className={cn(
|
||||
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
|
||||
"group/field-group @container/field-group flex w-full flex-col gap-3 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -2,9 +2,9 @@ import type { Beneficiary } from "@module/payment/client";
|
||||
|
||||
import { loadBeneficiaries, payment } from "@/database/payment.ts";
|
||||
import { Controller } from "@/lib/controller.tsx";
|
||||
import { User } from "@/services/user.ts";
|
||||
import { auth } from "@/services/auth.ts";
|
||||
|
||||
export class PaymentDashboardController extends Controller<{
|
||||
export class DashboardController extends Controller<{
|
||||
beneficiaries: Beneficiary[];
|
||||
}> {
|
||||
#subscriptions: any[] = [];
|
||||
@@ -28,7 +28,7 @@ export class PaymentDashboardController extends Controller<{
|
||||
this.#subscriptions.push(
|
||||
await payment
|
||||
.collection("beneficiary")
|
||||
.subscribe({ tenantId: await User.getTenantId() }, { sort: { label: 1 } }, (documents) => {
|
||||
.subscribe({ tenantId: auth.user.tenant.id }, { sort: { label: 1 } }, (documents) => {
|
||||
this.setState("beneficiaries", documents);
|
||||
}),
|
||||
);
|
||||
@@ -1,43 +1,89 @@
|
||||
import type { Account, Beneficiary, Ledger, Transaction, Wallet } from "@module/payment/client";
|
||||
import { IndexedDatabase } from "@valkyr/db";
|
||||
import {
|
||||
AccountSchema,
|
||||
BeneficiarySchema,
|
||||
LedgerSchema,
|
||||
TransactionSchema,
|
||||
WalletSchema,
|
||||
} from "@module/payment/client";
|
||||
import { IndexedDB } from "@valkyr/db";
|
||||
|
||||
import { api } from "@/services/api.ts";
|
||||
|
||||
export const payment = new IndexedDatabase<{
|
||||
account: Account;
|
||||
beneficiary: Beneficiary;
|
||||
ledger: Ledger;
|
||||
transaction: Transaction;
|
||||
wallet: Wallet;
|
||||
}>({
|
||||
export const payment = new IndexedDB({
|
||||
name: "valkyr-sandbox:payment",
|
||||
registrars: [
|
||||
{
|
||||
name: "account",
|
||||
indexes: [["_id", { unique: true }], ["walletId"]],
|
||||
schema: AccountSchema.shape,
|
||||
indexes: [
|
||||
{
|
||||
field: "_id",
|
||||
kind: "primary",
|
||||
},
|
||||
{
|
||||
field: "walletId",
|
||||
kind: "shared",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "beneficiary",
|
||||
indexes: [["_id", { unique: true }], ["tenantId"]],
|
||||
schema: BeneficiarySchema.shape,
|
||||
indexes: [
|
||||
{
|
||||
field: "_id",
|
||||
kind: "primary",
|
||||
},
|
||||
{
|
||||
field: "tenantId",
|
||||
kind: "shared",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "ledger",
|
||||
indexes: [["_id", { unique: true }], ["beneficiaryId"]],
|
||||
schema: LedgerSchema.shape,
|
||||
indexes: [
|
||||
{
|
||||
field: "_id",
|
||||
kind: "primary",
|
||||
},
|
||||
{
|
||||
field: "beneficiaryId",
|
||||
kind: "shared",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "transaction",
|
||||
indexes: [["_id", { unique: true }]],
|
||||
schema: TransactionSchema.shape,
|
||||
indexes: [
|
||||
{
|
||||
field: "_id",
|
||||
kind: "primary",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "wallet",
|
||||
indexes: [["_id", { unique: true }], ["ledgerId"]],
|
||||
schema: WalletSchema.shape,
|
||||
indexes: [
|
||||
{
|
||||
field: "_id",
|
||||
kind: "primary",
|
||||
},
|
||||
{
|
||||
field: "ledgerId",
|
||||
kind: "shared",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
] as const,
|
||||
});
|
||||
|
||||
export async function loadBeneficiaries(): Promise<void> {
|
||||
const result = await api.payment.benficiaries.list();
|
||||
if ("data" in result) {
|
||||
payment.collection("beneficiary").insertMany(result.data);
|
||||
payment.collection("beneficiary").insert(result.data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ export abstract class Controller<
|
||||
return;
|
||||
}
|
||||
const state = await this.onInit();
|
||||
if (this.#destroyed === false) {
|
||||
if (this.#destroyed === false && state !== undefined) {
|
||||
this.setState(state);
|
||||
}
|
||||
this.#initiated = true;
|
||||
@@ -64,10 +64,7 @@ export abstract class Controller<
|
||||
if (this.onResolve === undefined) {
|
||||
return;
|
||||
}
|
||||
const state: Partial<TState> = await this.onResolve();
|
||||
if (this.#destroyed === false) {
|
||||
this.setState({ ...this.state, ...state });
|
||||
}
|
||||
await this.onResolve();
|
||||
}
|
||||
|
||||
async $destroy(): Promise<void> {
|
||||
@@ -84,16 +81,12 @@ export abstract class Controller<
|
||||
/**
|
||||
* Called every time props change (including first mount).
|
||||
*/
|
||||
async onInit(): Promise<Partial<TState>> {
|
||||
return {};
|
||||
}
|
||||
async onInit(): Promise<Partial<TState> | void> {}
|
||||
|
||||
/**
|
||||
* Called every time props change (including first mount).
|
||||
*/
|
||||
async onResolve(): Promise<Partial<TState>> {
|
||||
return {};
|
||||
}
|
||||
async onResolve(): Promise<void> {}
|
||||
|
||||
/**
|
||||
* Called when the controller is destroyed.
|
||||
|
||||
28
app/src/lib/router.ts
Normal file
28
app/src/lib/router.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { createRouter } from "@tanstack/react-router";
|
||||
|
||||
import { auth } from "@/services/auth.ts";
|
||||
|
||||
import { routeTree } from "../routeTree.gen.ts";
|
||||
|
||||
export const router = createRouter({
|
||||
routeTree,
|
||||
defaultPreload: "intent",
|
||||
scrollRestoration: true,
|
||||
context: {
|
||||
auth,
|
||||
},
|
||||
});
|
||||
|
||||
export function getRouteParam(key: string): string {
|
||||
return getRouteParams()[key] ?? "";
|
||||
}
|
||||
|
||||
export function getRouteParams(): Record<string, string> {
|
||||
return router.state.matches.at(-1)?.params ?? {};
|
||||
}
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: typeof router;
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { createRootRoute, createRoute, createRouter, redirect } from "@tanstack/react-router";
|
||||
|
||||
import { AppView } from "@/app/app.view.tsx";
|
||||
import { CallbackView } from "@/app/auth/callback.view.tsx";
|
||||
import { LoginView } from "@/app/auth/login.view.tsx";
|
||||
import { DashboardView } from "@/app/dashboard/dashboard.view.tsx";
|
||||
import { zitadel } from "@/services/zitadel.ts";
|
||||
|
||||
import { PaymentDashboardView } from "../app/payment/dashboard/dashboard.view.tsx";
|
||||
|
||||
const root = createRootRoute();
|
||||
|
||||
const callback = createRoute({
|
||||
getParentRoute: () => root,
|
||||
path: "/auth/callback",
|
||||
component: CallbackView,
|
||||
});
|
||||
|
||||
const login = createRoute({
|
||||
getParentRoute: () => root,
|
||||
path: "/login",
|
||||
component: LoginView,
|
||||
});
|
||||
|
||||
const app = createRoute({
|
||||
id: "app",
|
||||
getParentRoute: () => root,
|
||||
beforeLoad: async () => {
|
||||
const user = await zitadel.userManager.getUser();
|
||||
if (user === null) {
|
||||
throw redirect({ to: "/login" });
|
||||
}
|
||||
if (user.expired === true) {
|
||||
throw redirect({ to: "/login" });
|
||||
}
|
||||
},
|
||||
component: AppView,
|
||||
});
|
||||
|
||||
const dashboard = createRoute({
|
||||
getParentRoute: () => app,
|
||||
path: "/",
|
||||
component: DashboardView,
|
||||
});
|
||||
|
||||
const payment = [
|
||||
createRoute({
|
||||
getParentRoute: () => app,
|
||||
path: "/payment",
|
||||
component: PaymentDashboardView,
|
||||
}),
|
||||
];
|
||||
|
||||
root.addChildren([app, login, callback]);
|
||||
app.addChildren([dashboard, ...payment]);
|
||||
|
||||
export const router = createRouter({ routeTree: root });
|
||||
@@ -3,14 +3,9 @@ import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import { ThemeProvider } from "@/components/theme-provider.tsx";
|
||||
import { router } from "@/lib/router.tsx";
|
||||
import "./index.css";
|
||||
import { router } from "@/lib/router";
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: typeof router;
|
||||
}
|
||||
}
|
||||
import "./index.css";
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
if (rootElement === null) {
|
||||
|
||||
126
app/src/routeTree.gen.ts
Normal file
126
app/src/routeTree.gen.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/* eslint-disable */
|
||||
|
||||
// @ts-nocheck
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
|
||||
// This file was automatically generated by TanStack Router.
|
||||
// You should NOT make any changes in this file as it will be overwritten.
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as CallbackRouteImport } from './routes/callback'
|
||||
import { Route as AuthRouteRouteImport } from './routes/_auth/route'
|
||||
import { Route as AuthIndexRouteImport } from './routes/_auth/index'
|
||||
import { Route as AuthBeneficiariesBeneficiaryIdRouteImport } from './routes/_auth/beneficiaries/$beneficiaryId'
|
||||
|
||||
const CallbackRoute = CallbackRouteImport.update({
|
||||
id: '/callback',
|
||||
path: '/callback',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AuthRouteRoute = AuthRouteRouteImport.update({
|
||||
id: '/_auth',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AuthIndexRoute = AuthIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => AuthRouteRoute,
|
||||
} as any)
|
||||
const AuthBeneficiariesBeneficiaryIdRoute =
|
||||
AuthBeneficiariesBeneficiaryIdRouteImport.update({
|
||||
id: '/beneficiaries/$beneficiaryId',
|
||||
path: '/beneficiaries/$beneficiaryId',
|
||||
getParentRoute: () => AuthRouteRoute,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/callback': typeof CallbackRoute
|
||||
'/': typeof AuthIndexRoute
|
||||
'/beneficiaries/$beneficiaryId': typeof AuthBeneficiariesBeneficiaryIdRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/callback': typeof CallbackRoute
|
||||
'/': typeof AuthIndexRoute
|
||||
'/beneficiaries/$beneficiaryId': typeof AuthBeneficiariesBeneficiaryIdRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/_auth': typeof AuthRouteRouteWithChildren
|
||||
'/callback': typeof CallbackRoute
|
||||
'/_auth/': typeof AuthIndexRoute
|
||||
'/_auth/beneficiaries/$beneficiaryId': typeof AuthBeneficiariesBeneficiaryIdRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/callback' | '/' | '/beneficiaries/$beneficiaryId'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/callback' | '/' | '/beneficiaries/$beneficiaryId'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/_auth'
|
||||
| '/callback'
|
||||
| '/_auth/'
|
||||
| '/_auth/beneficiaries/$beneficiaryId'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
AuthRouteRoute: typeof AuthRouteRouteWithChildren
|
||||
CallbackRoute: typeof CallbackRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/callback': {
|
||||
id: '/callback'
|
||||
path: '/callback'
|
||||
fullPath: '/callback'
|
||||
preLoaderRoute: typeof CallbackRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/_auth': {
|
||||
id: '/_auth'
|
||||
path: ''
|
||||
fullPath: ''
|
||||
preLoaderRoute: typeof AuthRouteRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/_auth/': {
|
||||
id: '/_auth/'
|
||||
path: '/'
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof AuthIndexRouteImport
|
||||
parentRoute: typeof AuthRouteRoute
|
||||
}
|
||||
'/_auth/beneficiaries/$beneficiaryId': {
|
||||
id: '/_auth/beneficiaries/$beneficiaryId'
|
||||
path: '/beneficiaries/$beneficiaryId'
|
||||
fullPath: '/beneficiaries/$beneficiaryId'
|
||||
preLoaderRoute: typeof AuthBeneficiariesBeneficiaryIdRouteImport
|
||||
parentRoute: typeof AuthRouteRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface AuthRouteRouteChildren {
|
||||
AuthIndexRoute: typeof AuthIndexRoute
|
||||
AuthBeneficiariesBeneficiaryIdRoute: typeof AuthBeneficiariesBeneficiaryIdRoute
|
||||
}
|
||||
|
||||
const AuthRouteRouteChildren: AuthRouteRouteChildren = {
|
||||
AuthIndexRoute: AuthIndexRoute,
|
||||
AuthBeneficiariesBeneficiaryIdRoute: AuthBeneficiariesBeneficiaryIdRoute,
|
||||
}
|
||||
|
||||
const AuthRouteRouteWithChildren = AuthRouteRoute._addFileChildren(
|
||||
AuthRouteRouteChildren,
|
||||
)
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
AuthRouteRoute: AuthRouteRouteWithChildren,
|
||||
CallbackRoute: CallbackRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>()
|
||||
19
app/src/routes/__root.tsx
Normal file
19
app/src/routes/__root.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { createRootRouteWithContext, Outlet } from "@tanstack/react-router";
|
||||
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
|
||||
|
||||
import type { AuthContext } from "@/services/auth.ts";
|
||||
|
||||
export const Route = createRootRouteWithContext<{
|
||||
auth: AuthContext;
|
||||
}>()({
|
||||
component: RootComponent,
|
||||
});
|
||||
|
||||
function RootComponent() {
|
||||
return (
|
||||
<>
|
||||
<Outlet />
|
||||
<TanStackRouterDevtools position="bottom-right" initialIsOpen={false} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
12
app/src/routes/_auth/beneficiaries/$beneficiaryId.tsx
Normal file
12
app/src/routes/_auth/beneficiaries/$beneficiaryId.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
import { BeneficiaryComponent } from "@/components/beneficiary/read.component.tsx";
|
||||
|
||||
export const Route = createFileRoute("/_auth/beneficiaries/$beneficiaryId")({
|
||||
component: BeneficiaryRoute,
|
||||
});
|
||||
|
||||
function BeneficiaryRoute() {
|
||||
const { beneficiaryId } = Route.useParams();
|
||||
return <BeneficiaryComponent id={beneficiaryId} />;
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
import { DashboardController } from "@/controllers/dashboard.controller.ts";
|
||||
import { makeControllerComponent } from "@/lib/controller.tsx";
|
||||
|
||||
import { PaymentDashboardController } from "./dashboard.controller.ts";
|
||||
|
||||
export const PaymentDashboardView = makeControllerComponent(PaymentDashboardController, ({ beneficiaries }) => {
|
||||
const DashboardComponent = makeControllerComponent(DashboardController, ({ beneficiaries }) => {
|
||||
if (beneficiaries.length === 0) {
|
||||
return (
|
||||
<div className="w-full flex flex-col pt-20 items-center justify-center text-center">
|
||||
@@ -20,3 +21,7 @@ export const PaymentDashboardView = makeControllerComponent(PaymentDashboardCont
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const Route = createFileRoute("/_auth/")({
|
||||
component: DashboardComponent,
|
||||
});
|
||||
@@ -1,10 +1,20 @@
|
||||
import { Outlet } from "@tanstack/react-router";
|
||||
import { createFileRoute, Outlet } from "@tanstack/react-router";
|
||||
|
||||
import { AppSidebar } from "@/components/app-sidebar";
|
||||
import { SiteHeader } from "@/components/site-header";
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||
|
||||
export function AppView() {
|
||||
export const Route = createFileRoute("/_auth")({
|
||||
beforeLoad: async ({ context: { auth } }) => {
|
||||
await auth.resolve();
|
||||
if (auth.isAuthenticated === false) {
|
||||
throw auth.login();
|
||||
}
|
||||
},
|
||||
component: AppLayout,
|
||||
});
|
||||
|
||||
function AppLayout() {
|
||||
return (
|
||||
<SidebarProvider
|
||||
style={
|
||||
35
app/src/routes/callback.tsx
Normal file
35
app/src/routes/callback.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import z from "zod";
|
||||
|
||||
import { zitadel } from "@/services/zitadel.ts";
|
||||
|
||||
export const Route = createFileRoute("/callback")({
|
||||
validateSearch: z.object({
|
||||
code: z.string(),
|
||||
}),
|
||||
component: AuthComponent,
|
||||
});
|
||||
|
||||
function AuthComponent() {
|
||||
const navigate = useNavigate();
|
||||
const [error, setError] = useState();
|
||||
|
||||
useEffect(() => {
|
||||
zitadel.userManager
|
||||
.signinRedirectCallback()
|
||||
.then(() => {
|
||||
navigate({ to: "/", replace: true });
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Callback error", error);
|
||||
setError(error);
|
||||
});
|
||||
}, [navigate]);
|
||||
|
||||
if (error) {
|
||||
return <div>Error!</div>;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
32
app/src/services/auth.ts
Normal file
32
app/src/services/auth.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { User } from "@/services/user.ts";
|
||||
import { zitadel } from "@/services/zitadel.ts";
|
||||
|
||||
export const auth: AuthContext = {
|
||||
get isAuthenticated() {
|
||||
return User.isAuthenticated;
|
||||
},
|
||||
|
||||
get user() {
|
||||
return User.instance;
|
||||
},
|
||||
|
||||
async resolve() {
|
||||
await User.resolve();
|
||||
},
|
||||
|
||||
async login() {
|
||||
await zitadel.authorize();
|
||||
},
|
||||
|
||||
async logout() {
|
||||
await zitadel.signout();
|
||||
},
|
||||
};
|
||||
|
||||
export type AuthContext = {
|
||||
isAuthenticated: boolean;
|
||||
user: User;
|
||||
resolve: () => Promise<void>;
|
||||
login: () => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
import { redirect } from "@tanstack/react-router";
|
||||
import z from "zod";
|
||||
|
||||
import { type ZitadelUser, zitadel } from "./zitadel.ts";
|
||||
@@ -35,20 +34,23 @@ export class User {
|
||||
this.tenant = tenant;
|
||||
}
|
||||
|
||||
static async getTenantId() {
|
||||
return User.get().then((user) => user.tenant.id);
|
||||
static get isAuthenticated() {
|
||||
return cached !== undefined;
|
||||
}
|
||||
|
||||
static async getTenantName() {
|
||||
return User.get().then((user) => user.tenant.name);
|
||||
}
|
||||
|
||||
static async get() {
|
||||
const user = await User.resolve();
|
||||
if (user === undefined) {
|
||||
throw redirect({ to: "/login" });
|
||||
static get instance() {
|
||||
if (cached === undefined) {
|
||||
throw zitadel.authorize();
|
||||
}
|
||||
return user;
|
||||
return cached;
|
||||
}
|
||||
|
||||
static get tenantId() {
|
||||
return User.instance.tenant.id;
|
||||
}
|
||||
|
||||
static get tenantName() {
|
||||
return User.instance.tenant.name;
|
||||
}
|
||||
|
||||
static async resolve() {
|
||||
|
||||
@@ -4,7 +4,7 @@ const config: ZitadelConfig = {
|
||||
authority: "https://iam.valkyrjs.com",
|
||||
project_resource_id: "348389288439709700",
|
||||
client_id: "348389308220112900",
|
||||
redirect_uri: "http://localhost:5173/auth/callback",
|
||||
redirect_uri: "http://localhost:5173/callback",
|
||||
post_logout_redirect_uri: "http://localhost:5173",
|
||||
response_type: "code",
|
||||
scope: "openid profile email urn:zitadel:iam:user:metadata urn:zitadel:iam:org:id:348388915649970180",
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import path from "node:path";
|
||||
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { tanstackRouter } from "@tanstack/router-plugin/vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
tanstackRouter({
|
||||
target: "react",
|
||||
autoCodeSplitting: true,
|
||||
}),
|
||||
react(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
"tailwindcss": "4.1.13",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"zod": "4.1.13"
|
||||
"zod": "4.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9.35.0",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.2.4/schema.json",
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"formatWithErrors": false,
|
||||
|
||||
@@ -12,34 +12,34 @@ services:
|
||||
# --------------------------------------------------------------------------------
|
||||
# Used by event store and read store for managing and reading application data.
|
||||
|
||||
mongo:
|
||||
restart: unless-stopped
|
||||
image: mongo:8
|
||||
container_name: boilerplate_mongo
|
||||
ports:
|
||||
- 6017:27017
|
||||
environment:
|
||||
MONGO_INITDB_ROOT_USERNAME: root
|
||||
MONGO_INITDB_ROOT_PASSWORD: password
|
||||
volumes:
|
||||
- mongo:/data/db
|
||||
networks:
|
||||
- server
|
||||
# mongo:
|
||||
# restart: unless-stopped
|
||||
# image: mongo:8
|
||||
# container_name: boilerplate_mongo
|
||||
# ports:
|
||||
# - 6017:27017
|
||||
# environment:
|
||||
# MONGO_INITDB_ROOT_USERNAME: root
|
||||
# MONGO_INITDB_ROOT_PASSWORD: password
|
||||
# volumes:
|
||||
# - mongo:/data/db
|
||||
# networks:
|
||||
# - server
|
||||
|
||||
# Cerbos
|
||||
# --------------------------------------------------------------------------------
|
||||
# Policy engine for application access control.
|
||||
|
||||
cerbos:
|
||||
restart: unless-stopped
|
||||
image: ghcr.io/cerbos/cerbos:latest
|
||||
container_name: boilerplate_cerbos
|
||||
command: ["server", "--config=/config.yaml"]
|
||||
ports:
|
||||
- 6592:3592
|
||||
- 6593:3593
|
||||
- 6594:3594
|
||||
volumes:
|
||||
- ./cerbos.yaml:/config.yaml
|
||||
networks:
|
||||
- server
|
||||
# cerbos:
|
||||
# restart: unless-stopped
|
||||
# image: ghcr.io/cerbos/cerbos:latest
|
||||
# container_name: boilerplate_cerbos
|
||||
# command: ["server", "--config=/config.yaml"]
|
||||
# ports:
|
||||
# - 6592:3592
|
||||
# - 6593:3593
|
||||
# - 6594:3594
|
||||
# volumes:
|
||||
# - ./cerbos.yaml:/config.yaml
|
||||
# networks:
|
||||
# - server
|
||||
|
||||
@@ -9,6 +9,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@platform/relay": "workspace:*",
|
||||
"zod": "4.1.13"
|
||||
"zod": "4.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,6 @@
|
||||
"@platform/database": "workspace:*",
|
||||
"@platform/parse": "workspace:*",
|
||||
"@platform/relay": "workspace:*",
|
||||
"zod": "4.1.13"
|
||||
"zod": "4.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,47 +7,72 @@ import { type Account, type AccountInsert, AccountInsertSchema, AccountSchema }
|
||||
* Create a new account.
|
||||
*
|
||||
* @param values - Account values to insert.
|
||||
*
|
||||
* @returns Account _id
|
||||
*/
|
||||
export async function createAccount(values: AccountInsert): Promise<string> {
|
||||
return db
|
||||
.begin(async () => {
|
||||
const _id = crypto.randomUUID();
|
||||
|
||||
// Assert wallet exists
|
||||
// ### Assert Wallet
|
||||
// Ensure that the wallet we are creating an account for exists.
|
||||
|
||||
await db.sql`
|
||||
ASSERT EXISTS (
|
||||
SELECT 1
|
||||
FROM payment.wallet w
|
||||
WHERE w._id = ${db.text(values.walletId)}
|
||||
), 'missing_wallet';
|
||||
SELECT
|
||||
1
|
||||
FROM
|
||||
payment.wallet wallet
|
||||
WHERE
|
||||
wallet._id = ${db.text(values.walletId)}
|
||||
),
|
||||
'missing_wallet';
|
||||
`;
|
||||
|
||||
// Assert wallet → ledger relationship exists AND ledger exists
|
||||
// ### Assert Wallet → Ledger
|
||||
// Ensure that the wallet is related to a existing ledger.
|
||||
|
||||
await db.sql`
|
||||
ASSERT EXISTS (
|
||||
SELECT 1
|
||||
FROM payment.wallet w
|
||||
JOIN payment.ledger l ON l._id = w."ledgerId"
|
||||
WHERE w._id = ${db.text(values.walletId)}
|
||||
), 'missing_ledger';
|
||||
SELECT
|
||||
1
|
||||
FROM
|
||||
payment.wallet wallet
|
||||
JOIN
|
||||
payment.ledger ledger ON ledger._id = wallet."ledgerId"
|
||||
WHERE
|
||||
wallet._id = ${db.text(values.walletId)}
|
||||
),
|
||||
'missing_ledger';
|
||||
`;
|
||||
|
||||
// Assert ledger supports the currency
|
||||
// ### Assert Currency
|
||||
// Ensure that the account currency is supported by the ledger.
|
||||
|
||||
await db.sql`
|
||||
ASSERT EXISTS (
|
||||
SELECT 1
|
||||
FROM payment.wallet w
|
||||
JOIN payment.ledger l ON l._id = w."ledgerId"
|
||||
WHERE w._id = ${db.text(values.walletId)}
|
||||
AND ${db.text(values.currency)} IN (
|
||||
SELECT
|
||||
1
|
||||
FROM
|
||||
payment.wallet wallet
|
||||
JOIN
|
||||
payment.ledger ledger ON ledger._id = wallet."ledgerId"
|
||||
WHERE
|
||||
wallet._id = ${db.text(values.walletId)}
|
||||
AND
|
||||
${db.text(values.currency)} IN (
|
||||
SELECT
|
||||
currency
|
||||
FROM
|
||||
UNNEST(l.currencies) AS x(currency)
|
||||
UNNEST(ledger.currencies) AS x(currency)
|
||||
)
|
||||
), 'unsupported_currency';
|
||||
),
|
||||
'unsupported_currency';
|
||||
`;
|
||||
|
||||
// ### Create Account
|
||||
|
||||
await db.sql`INSERT INTO payment.account RECORDS ${db.transit({ _id, ...AccountInsertSchema.parse(values) })}`;
|
||||
|
||||
return _id;
|
||||
@@ -71,6 +96,13 @@ export async function createAccount(values: AccountInsert): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all accounts registered for a wallet.
|
||||
*
|
||||
* @param walletId - Wallet to fetch accounts from.
|
||||
*
|
||||
* @returns List of wallet accounts
|
||||
*/
|
||||
export async function getAccountsByWalletId(walletId: string): Promise<Account[]> {
|
||||
return db.schema(AccountSchema).many`
|
||||
SELECT
|
||||
|
||||
@@ -56,6 +56,8 @@ export async function getBeneficiaryByTenantId(tenantId: string): Promise<Benefi
|
||||
* Get a beneficiary entity by provided id.
|
||||
*
|
||||
* @param id - Identity of the beneficiary to retrieve.
|
||||
*
|
||||
* @returns Beneficiary or undefined
|
||||
*/
|
||||
export async function getBeneficiaryById(id: string): Promise<Beneficiary | undefined> {
|
||||
return db.schema(BeneficiarySchema).one`
|
||||
|
||||
@@ -7,12 +7,31 @@ import { type Ledger, type LedgerInsert, LedgerSchema } from "../schemas/ledger.
|
||||
* Create a new ledger.
|
||||
*
|
||||
* @param values - Ledger values to insert.
|
||||
*
|
||||
* @returns Ledger _id
|
||||
*/
|
||||
export async function createLedger(values: LedgerInsert): Promise<string> {
|
||||
return db
|
||||
.begin(async () => {
|
||||
const _id = crypto.randomUUID();
|
||||
await db.sql`ASSERT EXISTS (SELECT 1 FROM payment.beneficiary WHERE _id = ${db.text(values.beneficiaryId)}), 'missing_beneficiary'`;
|
||||
|
||||
// ### Assert Beneficiary
|
||||
// Ensure the beneficiary for the ledger exists.
|
||||
|
||||
await db.sql`
|
||||
ASSERT EXISTS (
|
||||
SELECT
|
||||
1
|
||||
FROM
|
||||
payment.beneficiary
|
||||
WHERE
|
||||
_id = ${db.text(values.beneficiaryId)}
|
||||
),
|
||||
'missing_beneficiary'
|
||||
`;
|
||||
|
||||
// ### Create Ledger
|
||||
|
||||
await db.sql`
|
||||
INSERT INTO payment.ledger (
|
||||
_id,
|
||||
@@ -26,6 +45,7 @@ export async function createLedger(values: LedgerInsert): Promise<string> {
|
||||
${values.label ? db.text(values.label) : null}
|
||||
)
|
||||
`;
|
||||
|
||||
return _id;
|
||||
})
|
||||
.catch((error) => {
|
||||
|
||||
@@ -7,13 +7,33 @@ import { type Wallet, type WalletInsert, WalletInsertSchema, WalletSchema } from
|
||||
* Create a new wallet.
|
||||
*
|
||||
* @param values - Wallet values to insert.
|
||||
*
|
||||
* @returns Wallet _id
|
||||
*/
|
||||
export async function createWallet(values: WalletInsert): Promise<string> {
|
||||
return db
|
||||
.begin(async () => {
|
||||
const _id = crypto.randomUUID();
|
||||
await db.sql`ASSERT EXISTS (SELECT 1 FROM payment.ledger WHERE _id = ${db.text(values.ledgerId)}), 'missing_ledger'`;
|
||||
|
||||
// ### Assert Ledger
|
||||
// Ensure the ledger for the wallet exists.
|
||||
|
||||
await db.sql`
|
||||
ASSERT EXISTS (
|
||||
SELECT
|
||||
1
|
||||
FROM
|
||||
payment.ledger
|
||||
WHERE
|
||||
_id = ${db.text(values.ledgerId)}
|
||||
),
|
||||
'missing_ledger'
|
||||
`;
|
||||
|
||||
// ### Create Wallet
|
||||
|
||||
await db.sql`INSERT INTO payment.wallet RECORDS ${db.transit({ _id, ...WalletInsertSchema.parse(values) })}`;
|
||||
|
||||
return _id;
|
||||
})
|
||||
.catch((error) => {
|
||||
|
||||
@@ -11,6 +11,6 @@
|
||||
"@platform/database": "workspace:*",
|
||||
"@platform/parse": "workspace:*",
|
||||
"@platform/relay": "workspace:*",
|
||||
"zod": "4.1.13"
|
||||
"zod": "4.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@std/dotenv": "npm:@jsr/std__dotenv@0.225.5",
|
||||
"zod": "4.1.13"
|
||||
"zod": "4.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,6 @@
|
||||
"@types/transit-js": "0.8.3",
|
||||
"postgres": "3.4.7",
|
||||
"transit-js": "0.8.874",
|
||||
"zod": "4.1.13"
|
||||
"zod": "4.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,6 @@
|
||||
"dependencies": {
|
||||
"@platform/config": "workspace:*",
|
||||
"@valkyr/event-store": "npm:@jsr/valkyr__event-store@2.0.1",
|
||||
"zod": "4.1.13"
|
||||
"zod": "4.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
".": "./mod.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "4.1.13"
|
||||
"zod": "4.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,6 @@
|
||||
"@platform/socket": "workspace:*",
|
||||
"@platform/supertokens": "workspace:*",
|
||||
"path-to-regexp": "8",
|
||||
"zod": "4.1.13"
|
||||
"zod": "4.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,6 @@
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@platform/relay": "workspace:*",
|
||||
"zod": "4.1.13"
|
||||
"zod": "4.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,6 @@
|
||||
"@platform/socket": "workspace:*",
|
||||
"@platform/storage": "workspace:*",
|
||||
"@valkyr/json-rpc": "npm:@jsr/valkyr__json-rpc@1.1.0",
|
||||
"zod": "4.1.13"
|
||||
"zod": "4.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"dependencies": {
|
||||
"@platform/models": "workspace:*",
|
||||
"@platform/relay": "workspace:*",
|
||||
"zod": "4.1.13"
|
||||
"zod": "4.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user