refactor: move into monorepo
This commit is contained in:
42
apps/api/src/auth.ts
Normal file
42
apps/api/src/auth.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { genericOAuth } from "better-auth/plugins";
|
||||
import { expo } from "@better-auth/expo";
|
||||
import { drizzleSchema } from "@money/shared/db";
|
||||
import { db } from "./db";
|
||||
import { BASE_URL, HOST } from "@money/shared";
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: drizzleAdapter(db, {
|
||||
schema: drizzleSchema,
|
||||
provider: "pg",
|
||||
usePlural: true,
|
||||
}),
|
||||
trustedOrigins: [
|
||||
"http://localhost:8081",
|
||||
`exp://${HOST}:8081`,
|
||||
`${BASE_URL}:8081`,
|
||||
"https://money.koon.us",
|
||||
"money://",
|
||||
],
|
||||
advanced: {
|
||||
crossSubDomainCookies: {
|
||||
enabled: process.env.NODE_ENV == 'production',
|
||||
domain: "koon.us",
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
expo(),
|
||||
genericOAuth({
|
||||
config: [
|
||||
{
|
||||
providerId: 'koon-family',
|
||||
clientId: process.env.OAUTH_CLIENT_ID!,
|
||||
clientSecret: process.env.OAUTH_CLIENT_SECRET!,
|
||||
discoveryUrl: process.env.OAUTH_DISCOVERY_URL!,
|
||||
scopes: ["profile", "email"],
|
||||
}
|
||||
]
|
||||
})
|
||||
]
|
||||
});
|
||||
9
apps/api/src/db.ts
Normal file
9
apps/api/src/db.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { getDb } from "@money/shared/db";
|
||||
|
||||
if (!process.env.ZERO_UPSTREAM_DB) {
|
||||
throw new Error("ZERO_UPSTREAM_DB is not set");
|
||||
}
|
||||
|
||||
export const db = getDb({
|
||||
connectionString: process.env.ZERO_UPSTREAM_DB,
|
||||
});
|
||||
5
apps/api/src/hono.ts
Normal file
5
apps/api/src/hono.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { AuthData } from "@money/shared/auth";
|
||||
import { Hono } from "hono";
|
||||
|
||||
export const getHono = () =>
|
||||
new Hono<{ Variables: { auth: AuthData | null } }>();
|
||||
56
apps/api/src/index.ts
Normal file
56
apps/api/src/index.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { serve } from "@hono/node-server";
|
||||
import { authDataSchema } from "@money/shared/auth";
|
||||
import { BASE_URL } from "@money/shared";
|
||||
import { cors } from "hono/cors";
|
||||
import { auth } from "./auth";
|
||||
import { getHono } from "./hono";
|
||||
import { zero } from "./zero";
|
||||
|
||||
const app = getHono();
|
||||
|
||||
app.use(
|
||||
"/api/*",
|
||||
cors({
|
||||
origin: ['https://money.koon.us', `${BASE_URL}:8081`],
|
||||
allowMethods: ["POST", "GET", "OPTIONS"],
|
||||
allowHeaders: ["Content-Type", "Authorization"],
|
||||
credentials: true,
|
||||
}),
|
||||
);
|
||||
|
||||
app.on(["GET", "POST"], "/api/auth/*", (c) => auth.handler(c.req.raw));
|
||||
|
||||
app.use("*", async (c, next) => {
|
||||
const authHeader = c.req.raw.headers.get("Authorization");
|
||||
const cookie = authHeader?.split("Bearer ")[1];
|
||||
|
||||
const newHeaders = new Headers(c.req.raw.headers);
|
||||
|
||||
if (cookie) {
|
||||
newHeaders.set("Cookie", cookie);
|
||||
}
|
||||
|
||||
const session = await auth.api.getSession({ headers: newHeaders });
|
||||
|
||||
if (!session) {
|
||||
c.set("auth", null);
|
||||
return next();
|
||||
}
|
||||
c.set("auth", authDataSchema.parse(session));
|
||||
return next();
|
||||
});
|
||||
|
||||
app.route("/api/zero", zero);
|
||||
|
||||
app.get("/api", (c) => c.text("OK"));
|
||||
app.get("/", (c) => c.text("OK"));
|
||||
|
||||
serve(
|
||||
{
|
||||
fetch: app.fetch,
|
||||
port: process.env.PORT ? parseInt(process.env.PORT) : 3000,
|
||||
},
|
||||
(info) => {
|
||||
console.log(`Server is running on ${info.address}:${info.port}`);
|
||||
},
|
||||
);
|
||||
216
apps/api/src/zero.ts
Normal file
216
apps/api/src/zero.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import {
|
||||
type ReadonlyJSONValue,
|
||||
type Transaction,
|
||||
withValidation,
|
||||
} from "@rocicorp/zero";
|
||||
import {
|
||||
handleGetQueriesRequest,
|
||||
PushProcessor,
|
||||
ZQLDatabase,
|
||||
} from "@rocicorp/zero/server";
|
||||
import { PostgresJSConnection } from '@rocicorp/zero/pg';
|
||||
import postgres from 'postgres';
|
||||
import {
|
||||
createMutators as createMutatorsShared,
|
||||
isLoggedIn,
|
||||
queries,
|
||||
schema,
|
||||
type Mutators,
|
||||
type Schema,
|
||||
} from "@money/shared";
|
||||
import type { AuthData } from "@money/shared/auth";
|
||||
import { getHono } from "./hono";
|
||||
import { Configuration, CountryCode, PlaidApi, PlaidEnvironments, Products } from "plaid";
|
||||
import { randomUUID } from "crypto";
|
||||
import { db } from "./db";
|
||||
import { balance, plaidAccessTokens, plaidLink, transaction } from "@money/shared/db";
|
||||
import { eq, inArray, sql, type InferInsertModel } from "drizzle-orm";
|
||||
|
||||
|
||||
const configuration = new Configuration({
|
||||
basePath: process.env.PLAID_ENV == 'production' ? PlaidEnvironments.production : PlaidEnvironments.sandbox,
|
||||
baseOptions: {
|
||||
headers: {
|
||||
'PLAID-CLIENT-ID': process.env.PLAID_CLIENT_ID,
|
||||
'PLAID-SECRET': process.env.PLAID_SECRET,
|
||||
}
|
||||
}
|
||||
});
|
||||
const plaidClient = new PlaidApi(configuration);
|
||||
|
||||
|
||||
const processor = new PushProcessor(
|
||||
new ZQLDatabase(
|
||||
new PostgresJSConnection(postgres(process.env.ZERO_UPSTREAM_DB! as string)),
|
||||
schema,
|
||||
),
|
||||
);
|
||||
|
||||
type Tx = Transaction<Schema>;
|
||||
|
||||
const createMutators = (authData: AuthData | null) => {
|
||||
const mutators = createMutatorsShared(authData);
|
||||
return {
|
||||
...mutators,
|
||||
link: {
|
||||
...mutators.link,
|
||||
async create() {
|
||||
isLoggedIn(authData);
|
||||
console.log("Creating Link token");
|
||||
const r = await plaidClient.linkTokenCreate({
|
||||
user: {
|
||||
client_user_id: authData.user.id,
|
||||
},
|
||||
client_name: "Koon Money",
|
||||
language: "en",
|
||||
products: [Products.Transactions],
|
||||
country_codes: [CountryCode.Us],
|
||||
hosted_link: {}
|
||||
});
|
||||
console.log("Result", r);
|
||||
const { link_token, hosted_link_url } = r.data;
|
||||
|
||||
if (!hosted_link_url) throw Error("No link in response");
|
||||
|
||||
await db.insert(plaidLink).values({
|
||||
id: randomUUID() as string,
|
||||
user_id: authData.user.id,
|
||||
link: hosted_link_url,
|
||||
token: link_token,
|
||||
});
|
||||
},
|
||||
|
||||
async get(_, { link_token }) {
|
||||
isLoggedIn(authData);
|
||||
|
||||
const linkResp = await plaidClient.linkTokenGet({
|
||||
link_token,
|
||||
});
|
||||
if (!linkResp) throw Error("No link respo");
|
||||
console.log(JSON.stringify(linkResp.data, null, 4));
|
||||
const publicToken = linkResp.data.link_sessions?.at(0)?.results?.item_add_results.at(0)?.public_token;
|
||||
|
||||
if (!publicToken) throw Error("No public token");
|
||||
const { data } = await plaidClient.itemPublicTokenExchange({
|
||||
public_token: publicToken,
|
||||
})
|
||||
|
||||
await db.insert(plaidAccessTokens).values({
|
||||
id: randomUUID(),
|
||||
userId: authData.user.id,
|
||||
token: data.access_token,
|
||||
});
|
||||
},
|
||||
|
||||
async updateTransactions() {
|
||||
isLoggedIn(authData);
|
||||
const accounts = await db.query.plaidAccessTokens.findMany({
|
||||
where: eq(plaidAccessTokens.userId, authData.user.id),
|
||||
});
|
||||
if (accounts.length == 0) {
|
||||
console.error("No accounts");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const account of accounts) {
|
||||
const { data } = await plaidClient.transactionsGet({
|
||||
access_token: account.token,
|
||||
start_date: "2025-10-01",
|
||||
end_date: new Date().toISOString().split("T")[0],
|
||||
});
|
||||
|
||||
const transactions = data.transactions.map(tx => ({
|
||||
id: randomUUID(),
|
||||
user_id: authData.user.id,
|
||||
plaid_id: tx.transaction_id,
|
||||
account_id: tx.account_id,
|
||||
name: tx.name,
|
||||
amount: tx.amount as any,
|
||||
datetime: tx.datetime ? new Date(tx.datetime) : new Date(tx.date),
|
||||
authorized_datetime: tx.authorized_datetime ? new Date(tx.authorized_datetime) : undefined,
|
||||
json: JSON.stringify(tx),
|
||||
} satisfies InferInsertModel<typeof transaction>));
|
||||
|
||||
await db.insert(transaction).values(transactions).onConflictDoNothing({
|
||||
target: transaction.plaid_id,
|
||||
});
|
||||
|
||||
const txReplacingPendingIds = data.transactions
|
||||
.filter(t => t.pending_transaction_id)
|
||||
.map(t => t.pending_transaction_id!);
|
||||
|
||||
await db.delete(transaction)
|
||||
.where(inArray(transaction.plaid_id, txReplacingPendingIds));
|
||||
|
||||
}
|
||||
},
|
||||
|
||||
async updateBalences() {
|
||||
isLoggedIn(authData);
|
||||
const accounts = await db.query.plaidAccessTokens.findMany({
|
||||
where: eq(plaidAccessTokens.userId, authData.user.id),
|
||||
});
|
||||
if (accounts.length == 0) {
|
||||
console.error("No accounts");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const account of accounts) {
|
||||
const { data } = await plaidClient.accountsBalanceGet({
|
||||
access_token: account.token
|
||||
});
|
||||
await db.insert(balance).values(data.accounts.map(bal => ({
|
||||
id: randomUUID(),
|
||||
user_id: authData.user.id,
|
||||
plaid_id: bal.account_id,
|
||||
avaliable: bal.balances.available as any,
|
||||
current: bal.balances.current as any,
|
||||
name: bal.name,
|
||||
}))).onConflictDoUpdate({
|
||||
target: balance.plaid_id,
|
||||
set: { current: sql.raw(`excluded.${balance.current.name}`), avaliable: sql.raw(`excluded.${balance.avaliable.name}`) }
|
||||
})
|
||||
}
|
||||
|
||||
},
|
||||
}
|
||||
} as const satisfies Mutators;
|
||||
}
|
||||
|
||||
const zero = getHono()
|
||||
.post("/mutate", async (c) => {
|
||||
const authData = c.get("auth");
|
||||
|
||||
const result = await processor.process(createMutators(authData), c.req.raw);
|
||||
|
||||
return c.json(result);
|
||||
})
|
||||
.post("/get-queries", async (c) => {
|
||||
const authData = c.get("auth");
|
||||
|
||||
const result = await handleGetQueriesRequest(
|
||||
(name, args) => ({ query: getQuery(authData, name, args) }),
|
||||
schema,
|
||||
c.req.raw,
|
||||
);
|
||||
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
const validatedQueries = Object.fromEntries(
|
||||
Object.values(queries).map((q) => [q.queryName, withValidation(q)]),
|
||||
);
|
||||
|
||||
function getQuery(
|
||||
authData: AuthData | null,
|
||||
name: string,
|
||||
args: readonly ReadonlyJSONValue[],
|
||||
) {
|
||||
if (name in validatedQueries) {
|
||||
const q = validatedQueries[name];
|
||||
return q(authData, ...args);
|
||||
}
|
||||
throw new Error(`Unknown query: ${name}`);
|
||||
}
|
||||
|
||||
export { zero };
|
||||
Reference in New Issue
Block a user