functional auth

This commit is contained in:
shafi54 2026-05-23 20:57:22 +05:30
parent 51bfb1513a
commit dfea6814e9
17 changed files with 483 additions and 143 deletions

View file

@ -33,11 +33,14 @@ app.all("/trpc/*", async (c) => {
createContext: () => createContext({ req: c.req.raw, resHeaders }), createContext: () => createContext({ req: c.req.raw, resHeaders }),
}); });
resHeaders.forEach((value, key) => { const merged = new Headers(response.headers);
response.headers.set(key, value); resHeaders.forEach((value, key) => merged.set(key, value));
});
return response; return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: merged,
});
}); });
export { app }; export { app };

View file

@ -1,5 +1,5 @@
import { z } from "zod"; import { z } from "zod";
import { t } from "../../init"; import { protectedProcedure, router } from "../../init";
import { dataManager } from "../../../lib/data-manager-instance"; import { dataManager } from "../../../lib/data-manager-instance";
export const DistributorSchema = z.object({ export const DistributorSchema = z.object({
@ -25,39 +25,54 @@ export const UpdateDistributorInput = z
export type Distributor = z.infer<typeof DistributorSchema>; export type Distributor = z.infer<typeof DistributorSchema>;
export const distributorRouter = t.router({ export const distributorRouter = router({
list: t.procedure list: protectedProcedure
.output(z.array(DistributorSchema)) .output(z.array(DistributorSchema))
.query(() => dataManager.distributors.getDistributors()), .query(({ ctx }) =>
dataManager.distributors.getDistributors(ctx.staff.enterpriseId),
byId: t.procedure
.input(z.object({ id: z.number().int() }))
.output(DistributorSchema.nullable())
.query(({ input }) =>
dataManager.distributors.getDistributorById(input.id),
), ),
create: t.procedure byId: protectedProcedure
.input(z.object({ id: z.number().int() }))
.output(DistributorSchema.nullable())
.query(({ ctx, input }) =>
dataManager.distributors.getDistributorById(
input.id,
ctx.staff.enterpriseId,
),
),
create: protectedProcedure
.input(CreateDistributorInput) .input(CreateDistributorInput)
.output(DistributorSchema) .output(DistributorSchema)
.mutation(({ input }) => .mutation(({ ctx, input }) =>
dataManager.distributors.createDistributor(input), dataManager.distributors.createDistributor(
input,
ctx.staff.enterpriseId,
),
), ),
update: t.procedure update: protectedProcedure
.input(UpdateDistributorInput) .input(UpdateDistributorInput)
.output(DistributorSchema.nullable()) .output(DistributorSchema.nullable())
.mutation(({ input }) => { .mutation(({ ctx, input }) => {
const { id, ...patch } = input; const { id, ...patch } = input;
return dataManager.distributors.updateDistributor(id, patch); return dataManager.distributors.updateDistributor(
id,
patch,
ctx.staff.enterpriseId,
);
}), }),
remove: t.procedure remove: protectedProcedure
.input(z.object({ id: z.number().int() })) .input(z.object({ id: z.number().int() }))
.output(z.object({ ok: z.boolean() })) .output(z.object({ ok: z.boolean() }))
.mutation(async ({ input }) => { .mutation(async ({ ctx, input }) => {
const ok = const ok =
await dataManager.distributors.deleteDistributor(input.id); await dataManager.distributors.deleteDistributor(
input.id,
ctx.staff.enterpriseId,
);
return { ok }; return { ok };
}), }),
}); });

View file

@ -1,5 +1,5 @@
import { z } from "zod"; import { z } from "zod";
import { t } from "../../init"; import { protectedProcedure, router } from "../../init";
import { dataManager } from "../../../lib/data-manager-instance"; import { dataManager } from "../../../lib/data-manager-instance";
import { import {
ProductSchema, ProductSchema,
@ -7,34 +7,53 @@ import {
UpdateProductInput, UpdateProductInput,
} from "@repo/shared"; } from "@repo/shared";
export const productRouter = t.router({ export const productRouter = router({
list: t.procedure list: protectedProcedure
.output(z.array(ProductSchema)) .output(z.array(ProductSchema))
.query(() => dataManager.products.getProducts()), .query(({ ctx }) =>
dataManager.products.getProducts(ctx.staff.enterpriseId),
),
byId: t.procedure byId: protectedProcedure
.input(z.object({ id: z.number().int() })) .input(z.object({ id: z.number().int() }))
.output(ProductSchema.nullable()) .output(ProductSchema.nullable())
.query(({ input }) => dataManager.products.getProductById(input.id)), .query(({ ctx, input }) =>
dataManager.products.getProductById(
input.id,
ctx.staff.enterpriseId,
),
),
create: t.procedure create: protectedProcedure
.input(CreateProductInput) .input(CreateProductInput)
.output(ProductSchema) .output(ProductSchema)
.mutation(({ input }) => dataManager.products.createProduct(input)), .mutation(({ ctx, input }) =>
dataManager.products.createProduct(
input,
ctx.staff.enterpriseId,
),
),
update: t.procedure update: protectedProcedure
.input(UpdateProductInput) .input(UpdateProductInput)
.output(ProductSchema.nullable()) .output(ProductSchema.nullable())
.mutation(({ input }) => { .mutation(({ ctx, input }) => {
const { id, ...patch } = input; const { id, ...patch } = input;
return dataManager.products.updateProduct(id, patch); return dataManager.products.updateProduct(
id,
patch,
ctx.staff.enterpriseId,
);
}), }),
remove: t.procedure remove: protectedProcedure
.input(z.object({ id: z.number().int() })) .input(z.object({ id: z.number().int() }))
.output(z.object({ ok: z.boolean() })) .output(z.object({ ok: z.boolean() }))
.mutation(async ({ input }) => { .mutation(async ({ ctx, input }) => {
const ok = await dataManager.products.deleteProduct(input.id); const ok = await dataManager.products.deleteProduct(
input.id,
ctx.staff.enterpriseId,
);
return { ok }; return { ok };
}), }),
}); });

View file

@ -1,5 +1,5 @@
import { z } from "zod"; import { z } from "zod";
import { t } from "../../init"; import { protectedProcedure, router } from "../../init";
import { dataManager } from "../../../lib/data-manager-instance"; import { dataManager } from "../../../lib/data-manager-instance";
export const StockBatchSchema = z.object({ export const StockBatchSchema = z.object({
@ -11,6 +11,7 @@ export const StockBatchSchema = z.object({
expiry: z.string(), expiry: z.string(),
rack: z.object({ id: z.number().int(), name: z.string() }).nullable(), rack: z.object({ id: z.number().int(), name: z.string() }).nullable(),
distributor: z.object({ id: z.number().int(), agency: z.string() }).nullable(), distributor: z.object({ id: z.number().int(), agency: z.string() }).nullable(),
quantity: z.number().int(),
is_default: z.boolean(), is_default: z.boolean(),
}); });
@ -24,6 +25,7 @@ export const CreateStockBatchInput = z.object({
expiry: shape.expiry.min(1), expiry: shape.expiry.min(1),
rack_id: z.number().int().nullable().optional(), rack_id: z.number().int().nullable().optional(),
distributor_id: z.number().int().nullable().optional(), distributor_id: z.number().int().nullable().optional(),
quantity: z.number().int().min(1),
is_default: shape.is_default.default(false), is_default: shape.is_default.default(false),
}); });
@ -33,34 +35,53 @@ export const UpdateStockBatchInput = z
export type StockBatch = z.infer<typeof StockBatchSchema>; export type StockBatch = z.infer<typeof StockBatchSchema>;
export const stockRouter = t.router({ export const stockRouter = router({
list: t.procedure list: protectedProcedure
.output(z.array(StockBatchSchema)) .output(z.array(StockBatchSchema))
.query(() => dataManager.stockBatches.getStockBatches()), .query(({ ctx }) =>
dataManager.stockBatches.getStockBatches(ctx.staff.enterpriseId),
),
byId: t.procedure byId: protectedProcedure
.input(z.object({ id: z.number().int() })) .input(z.object({ id: z.number().int() }))
.output(StockBatchSchema.nullable()) .output(StockBatchSchema.nullable())
.query(({ input }) => dataManager.stockBatches.getStockBatchById(input.id)), .query(({ ctx, input }) =>
dataManager.stockBatches.getStockBatchById(
input.id,
ctx.staff.enterpriseId,
),
),
create: t.procedure create: protectedProcedure
.input(CreateStockBatchInput) .input(CreateStockBatchInput)
.output(StockBatchSchema) .output(StockBatchSchema)
.mutation(({ input }) => dataManager.stockBatches.createStockBatch(input)), .mutation(({ ctx, input }) =>
dataManager.stockBatches.createStockBatch(
input,
ctx.staff.enterpriseId,
),
),
update: t.procedure update: protectedProcedure
.input(UpdateStockBatchInput) .input(UpdateStockBatchInput)
.output(StockBatchSchema.nullable()) .output(StockBatchSchema.nullable())
.mutation(({ input }) => { .mutation(({ ctx, input }) => {
const { id, ...patch } = input; const { id, ...patch } = input;
return dataManager.stockBatches.updateStockBatch(id, patch); return dataManager.stockBatches.updateStockBatch(
id,
patch,
ctx.staff.enterpriseId,
);
}), }),
remove: t.procedure remove: protectedProcedure
.input(z.object({ id: z.number().int() })) .input(z.object({ id: z.number().int() }))
.output(z.object({ ok: z.boolean() })) .output(z.object({ ok: z.boolean() }))
.mutation(async ({ input }) => { .mutation(async ({ ctx, input }) => {
const ok = await dataManager.stockBatches.deleteStockBatch(input.id); const ok = await dataManager.stockBatches.deleteStockBatch(
input.id,
ctx.staff.enterpriseId,
);
return { ok }; return { ok };
}), }),
}); });

View file

@ -1,5 +1,5 @@
import { z } from "zod"; import { z } from "zod";
import {t} from '../../init' import { protectedProcedure, router } from "../../init";
import { dataManager } from "../../../lib/data-manager-instance"; import { dataManager } from "../../../lib/data-manager-instance";
export const StorageSpaceSchema = z.object({ export const StorageSpaceSchema = z.object({
@ -25,39 +25,54 @@ export const UpdateStorageInput = z
export type StorageSpace = z.infer<typeof StorageSpaceSchema>; export type StorageSpace = z.infer<typeof StorageSpaceSchema>;
export const storageRouter = t.router({ export const storageRouter = router({
list: t.procedure list: protectedProcedure
.output(z.array(StorageSpaceSchema)) .output(z.array(StorageSpaceSchema))
.query(() => dataManager.storageSpaces.getStorageSpaces()), .query(({ ctx }) =>
dataManager.storageSpaces.getStorageSpaces(ctx.staff.enterpriseId),
byId: t.procedure
.input(z.object({ id: z.number().int() }))
.output(StorageSpaceSchema.nullable())
.query(({ input }) =>
dataManager.storageSpaces.getStorageSpaceById(input.id),
), ),
create: t.procedure byId: protectedProcedure
.input(z.object({ id: z.number().int() }))
.output(StorageSpaceSchema.nullable())
.query(({ ctx, input }) =>
dataManager.storageSpaces.getStorageSpaceById(
input.id,
ctx.staff.enterpriseId,
),
),
create: protectedProcedure
.input(CreateStorageInput) .input(CreateStorageInput)
.output(StorageSpaceSchema) .output(StorageSpaceSchema)
.mutation(({ input }) => .mutation(({ ctx, input }) =>
dataManager.storageSpaces.createStorageSpace(input), dataManager.storageSpaces.createStorageSpace(
input,
ctx.staff.enterpriseId,
),
), ),
update: t.procedure update: protectedProcedure
.input(UpdateStorageInput) .input(UpdateStorageInput)
.output(StorageSpaceSchema.nullable()) .output(StorageSpaceSchema.nullable())
.mutation(({ input }) => { .mutation(({ ctx, input }) => {
const { id, ...patch } = input; const { id, ...patch } = input;
return dataManager.storageSpaces.updateStorageSpace(id, patch); return dataManager.storageSpaces.updateStorageSpace(
id,
patch,
ctx.staff.enterpriseId,
);
}), }),
remove: t.procedure remove: protectedProcedure
.input(z.object({ id: z.number().int() })) .input(z.object({ id: z.number().int() }))
.output(z.object({ ok: z.boolean() })) .output(z.object({ ok: z.boolean() }))
.mutation(async ({ input }) => { .mutation(async ({ ctx, input }) => {
const ok = const ok =
await dataManager.storageSpaces.deleteStorageSpace(input.id); await dataManager.storageSpaces.deleteStorageSpace(
input.id,
ctx.staff.enterpriseId,
);
return { ok }; return { ok };
}), }),
}); });

View file

@ -14,6 +14,7 @@ import { Route as StockRouteImport } from './routes/stock'
import { Route as StaffRouteImport } from './routes/staff' import { Route as StaffRouteImport } from './routes/staff'
import { Route as ProfileRouteImport } from './routes/profile' import { Route as ProfileRouteImport } from './routes/profile'
import { Route as ProductsRouteImport } from './routes/products' import { Route as ProductsRouteImport } from './routes/products'
import { Route as LoginRouteImport } from './routes/login'
import { Route as DistributorsRouteImport } from './routes/distributors' import { Route as DistributorsRouteImport } from './routes/distributors'
import { Route as CustomersRouteImport } from './routes/customers' import { Route as CustomersRouteImport } from './routes/customers'
import { Route as BillingRouteImport } from './routes/billing' import { Route as BillingRouteImport } from './routes/billing'
@ -56,6 +57,11 @@ const ProductsRoute = ProductsRouteImport.update({
path: '/products', path: '/products',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
getParentRoute: () => rootRouteImport,
} as any)
const DistributorsRoute = DistributorsRouteImport.update({ const DistributorsRoute = DistributorsRouteImport.update({
id: '/distributors', id: '/distributors',
path: '/distributors', path: '/distributors',
@ -142,6 +148,7 @@ export interface FileRoutesByFullPath {
'/billing': typeof BillingRoute '/billing': typeof BillingRoute
'/customers': typeof CustomersRoute '/customers': typeof CustomersRoute
'/distributors': typeof DistributorsRouteWithChildren '/distributors': typeof DistributorsRouteWithChildren
'/login': typeof LoginRoute
'/products': typeof ProductsRouteWithChildren '/products': typeof ProductsRouteWithChildren
'/profile': typeof ProfileRoute '/profile': typeof ProfileRoute
'/staff': typeof StaffRoute '/staff': typeof StaffRoute
@ -164,6 +171,7 @@ export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/billing': typeof BillingRoute '/billing': typeof BillingRoute
'/customers': typeof CustomersRoute '/customers': typeof CustomersRoute
'/login': typeof LoginRoute
'/profile': typeof ProfileRoute '/profile': typeof ProfileRoute
'/staff': typeof StaffRoute '/staff': typeof StaffRoute
'/distributors/$id': typeof DistributorsIdRoute '/distributors/$id': typeof DistributorsIdRoute
@ -185,6 +193,7 @@ export interface FileRoutesById {
'/billing': typeof BillingRoute '/billing': typeof BillingRoute
'/customers': typeof CustomersRoute '/customers': typeof CustomersRoute
'/distributors': typeof DistributorsRouteWithChildren '/distributors': typeof DistributorsRouteWithChildren
'/login': typeof LoginRoute
'/products': typeof ProductsRouteWithChildren '/products': typeof ProductsRouteWithChildren
'/profile': typeof ProfileRoute '/profile': typeof ProfileRoute
'/staff': typeof StaffRoute '/staff': typeof StaffRoute
@ -210,6 +219,7 @@ export interface FileRouteTypes {
| '/billing' | '/billing'
| '/customers' | '/customers'
| '/distributors' | '/distributors'
| '/login'
| '/products' | '/products'
| '/profile' | '/profile'
| '/staff' | '/staff'
@ -232,6 +242,7 @@ export interface FileRouteTypes {
| '/' | '/'
| '/billing' | '/billing'
| '/customers' | '/customers'
| '/login'
| '/profile' | '/profile'
| '/staff' | '/staff'
| '/distributors/$id' | '/distributors/$id'
@ -252,6 +263,7 @@ export interface FileRouteTypes {
| '/billing' | '/billing'
| '/customers' | '/customers'
| '/distributors' | '/distributors'
| '/login'
| '/products' | '/products'
| '/profile' | '/profile'
| '/staff' | '/staff'
@ -276,6 +288,7 @@ export interface RootRouteChildren {
BillingRoute: typeof BillingRoute BillingRoute: typeof BillingRoute
CustomersRoute: typeof CustomersRoute CustomersRoute: typeof CustomersRoute
DistributorsRoute: typeof DistributorsRouteWithChildren DistributorsRoute: typeof DistributorsRouteWithChildren
LoginRoute: typeof LoginRoute
ProductsRoute: typeof ProductsRouteWithChildren ProductsRoute: typeof ProductsRouteWithChildren
ProfileRoute: typeof ProfileRoute ProfileRoute: typeof ProfileRoute
StaffRoute: typeof StaffRoute StaffRoute: typeof StaffRoute
@ -320,6 +333,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ProductsRouteImport preLoaderRoute: typeof ProductsRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/login': {
id: '/login'
path: '/login'
fullPath: '/login'
preLoaderRoute: typeof LoginRouteImport
parentRoute: typeof rootRouteImport
}
'/distributors': { '/distributors': {
id: '/distributors' id: '/distributors'
path: '/distributors' path: '/distributors'
@ -501,6 +521,7 @@ const rootRouteChildren: RootRouteChildren = {
BillingRoute: BillingRoute, BillingRoute: BillingRoute,
CustomersRoute: CustomersRoute, CustomersRoute: CustomersRoute,
DistributorsRoute: DistributorsRouteWithChildren, DistributorsRoute: DistributorsRouteWithChildren,
LoginRoute: LoginRoute,
ProductsRoute: ProductsRouteWithChildren, ProductsRoute: ProductsRouteWithChildren,
ProfileRoute: ProfileRoute, ProfileRoute: ProfileRoute,
StaffRoute: StaffRoute, StaffRoute: StaffRoute,

View file

@ -1,7 +1,14 @@
import { Outlet, createRootRoute } from "@tanstack/react-router"; import { useEffect } from "react";
import {
Outlet,
createRootRoute,
useNavigate,
useRouterState,
} from "@tanstack/react-router";
import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools"; import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
import { TanStackDevtools } from "@tanstack/react-devtools"; import { TanStackDevtools } from "@tanstack/react-devtools";
import { AppLayout } from "#/components/AppLayout"; import { AppLayout } from "#/components/AppLayout";
import { AuthGate, useAuthStore } from "shared-react";
import "../styles.css"; import "../styles.css";
@ -9,12 +16,46 @@ export const Route = createRootRoute({
component: RootComponent, component: RootComponent,
}); });
function AuthGuard() {
const navigate = useNavigate();
const pathname = useRouterState({ select: (s) => s.location.pathname });
const isLoading = useAuthStore((s) => s.isLoading);
const staff = useAuthStore((s) => s.staff);
const isLoginPage = pathname === "/login";
useEffect(() => {
if (!isLoading && !staff && !isLoginPage) {
navigate({ to: "/login" });
}
}, [isLoading, staff, isLoginPage, navigate]);
if (isLoading) {
return (
<AppLayout>
<div className="flex items-center justify-center min-h-[60vh]">
<p className="text-sm text-slate-600">Loading...</p>
</div>
</AppLayout>
);
}
if (!staff && !isLoginPage) return null;
if (isLoginPage) return <Outlet />;
return (
<AppLayout>
<Outlet />
</AppLayout>
);
}
function RootComponent() { function RootComponent() {
return ( return (
<> <>
<AppLayout> <AuthGate>
<Outlet /> <AuthGuard />
</AppLayout> </AuthGate>
<TanStackDevtools <TanStackDevtools
config={{ config={{
position: "bottom-right", position: "bottom-right",

View file

@ -0,0 +1,117 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useLogin, useAuthStore } from "shared-react";
import { Button, Input } from "#/components/ui";
const formSchema = z.object({
username: z.string().min(1, "Username is required"),
password: z.string().min(1, "Password is required"),
});
type FormValues = z.infer<typeof formSchema>;
export const Route = createFileRoute("/login")({
component: LoginPage,
staticData: {
title: "Login",
subtitle: "Sign in to your account",
},
});
function LoginPage() {
const navigate = useNavigate();
const loginMutation = useLogin();
const setUser = useAuthStore((s) => s.setUser);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(formSchema),
});
function onSubmit(values: FormValues) {
loginMutation.mutate(values, {
onSuccess: (data) => {
setUser(data.staff, {
id: data.enterprise_id,
name: "",
type: "",
});
navigate({ to: "/" });
},
});
}
return (
<div className="flex items-center justify-center min-h-[60vh]">
<form
onSubmit={handleSubmit(onSubmit)}
className="bg-white rounded-lg shadow-[0_0_0_1px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)] p-8 w-full max-w-sm"
>
<div className="mb-6">
<h2 className="text-xl font-semibold text-slate-900 mb-1">
Sign In
</h2>
<p className="text-sm text-slate-600">
Enter your credentials to continue
</p>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-900 mb-1.5">
Username
</label>
<Input
{...register("username")}
variant={errors.username ? "error" : "default"}
placeholder="Enter your username"
autoComplete="username"
/>
{errors.username && (
<p className="text-sm text-red-600 mt-1">
{errors.username.message}
</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-slate-900 mb-1.5">
Password
</label>
<Input
type="password"
{...register("password")}
variant={errors.password ? "error" : "default"}
placeholder="Enter your password"
autoComplete="current-password"
/>
{errors.password && (
<p className="text-sm text-red-600 mt-1">
{errors.password.message}
</p>
)}
</div>
</div>
{loginMutation.error && (
<p className="text-sm text-red-600 mt-4">
Invalid username or password
</p>
)}
<Button
type="submit"
className="w-full mt-6"
disabled={loginMutation.isPending}
>
{loginMutation.isPending ? "Signing in..." : "Sign In"}
</Button>
</form>
</div>
);
}

View file

@ -15,8 +15,8 @@ sqlite.run("INSERT OR IGNORE INTO units (name) VALUES ('piece')")
sqlite.run("INSERT OR IGNORE INTO enterprises (id, name, type, owner_name, mobile) VALUES (1, 'Main Pharmacy', 'Pharmacy', 'Admin User', '0000000000')") sqlite.run("INSERT OR IGNORE INTO enterprises (id, name, type, owner_name, mobile) VALUES (1, 'Main Pharmacy', 'Pharmacy', 'Admin User', '0000000000')")
// Seed staff (admin user) // Seed staff (admin user)
const today = new Date().toISOString().split('T')[0] const today = new Date().toISOString().slice(0, 10)
const adminHash = bcrypt.hashSync('admin123', 10) const adminHash = bcrypt.hashSync('admin123', 10) || ''
sqlite.run( sqlite.run(
"INSERT OR IGNORE INTO staff (id, name, username, password, added_on, is_password_reset_needed) VALUES (1, 'Admin', 'admin', ?, ?, 1)", "INSERT OR IGNORE INTO staff (id, name, username, password, added_on, is_password_reset_needed) VALUES (1, 'Admin', 'admin', ?, ?, 1)",
[adminHash, today], [adminHash, today],

View file

@ -1,4 +1,4 @@
import { eq } from 'drizzle-orm' import { eq, and } from 'drizzle-orm'
import { db } from './db-instance' import { db } from './db-instance'
import { distributors } from './schema/distributors' import { distributors } from './schema/distributors'
@ -21,11 +21,11 @@ export type CreateDistributorInput = {
export type UpdateDistributorPatch = Partial<CreateDistributorInput> export type UpdateDistributorPatch = Partial<CreateDistributorInput>
export type DistributorsRepo = { export type DistributorsRepo = {
getDistributors: () => Promise<Distributor[]> getDistributors: (enterpriseId: number) => Promise<Distributor[]>
getDistributorById: (id: number) => Promise<Distributor | null> getDistributorById: (id: number, enterpriseId: number) => Promise<Distributor | null>
createDistributor: (input: CreateDistributorInput) => Promise<Distributor> createDistributor: (input: CreateDistributorInput, enterpriseId: number) => Promise<Distributor>
updateDistributor: (id: number, patch: UpdateDistributorPatch) => Promise<Distributor | null> updateDistributor: (id: number, patch: UpdateDistributorPatch, enterpriseId: number) => Promise<Distributor | null>
deleteDistributor: (id: number) => Promise<boolean> deleteDistributor: (id: number, enterpriseId: number) => Promise<boolean>
} }
function toDistributor(row: { function toDistributor(row: {
@ -48,21 +48,25 @@ export function createDistributorsRepo(): {
repo: DistributorsRepo repo: DistributorsRepo
} { } {
const repo: DistributorsRepo = { const repo: DistributorsRepo = {
getDistributors() { getDistributors(enterpriseId) {
const rows = db.select().from(distributors).all() const rows = db
.select()
.from(distributors)
.where(eq(distributors.enterpriseId, enterpriseId))
.all()
return Promise.resolve(rows.map(toDistributor)) return Promise.resolve(rows.map(toDistributor))
}, },
getDistributorById(id) { getDistributorById(id, enterpriseId) {
const row = db const row = db
.select() .select()
.from(distributors) .from(distributors)
.where(eq(distributors.id, id)) .where(and(eq(distributors.id, id), eq(distributors.enterpriseId, enterpriseId)))
.get() .get()
return Promise.resolve(row ? toDistributor(row) : null) return Promise.resolve(row ? toDistributor(row) : null)
}, },
createDistributor(input) { createDistributor(input, enterpriseId) {
const created = db const created = db
.insert(distributors) .insert(distributors)
.values({ .values({
@ -70,13 +74,14 @@ export function createDistributorsRepo(): {
contact: input.contact, contact: input.contact,
mobile: input.mobile, mobile: input.mobile,
address: input.address ?? null, address: input.address ?? null,
enterpriseId,
}) })
.returning() .returning()
.get() .get()
return Promise.resolve(toDistributor(created)) return Promise.resolve(toDistributor(created))
}, },
updateDistributor(id, patch) { updateDistributor(id, patch, enterpriseId) {
const updated = db const updated = db
.update(distributors) .update(distributors)
.set({ .set({
@ -85,16 +90,16 @@ export function createDistributorsRepo(): {
...(patch.mobile !== undefined ? { mobile: patch.mobile } : {}), ...(patch.mobile !== undefined ? { mobile: patch.mobile } : {}),
...(patch.address !== undefined ? { address: patch.address } : {}), ...(patch.address !== undefined ? { address: patch.address } : {}),
}) })
.where(eq(distributors.id, id)) .where(and(eq(distributors.id, id), eq(distributors.enterpriseId, enterpriseId)))
.returning() .returning()
.get() .get()
return Promise.resolve(updated ? toDistributor(updated) : null) return Promise.resolve(updated ? toDistributor(updated) : null)
}, },
deleteDistributor(id) { deleteDistributor(id, enterpriseId) {
const deleted = db const deleted = db
.delete(distributors) .delete(distributors)
.where(eq(distributors.id, id)) .where(and(eq(distributors.id, id), eq(distributors.enterpriseId, enterpriseId)))
.returning({ id: distributors.id }) .returning({ id: distributors.id })
.get() .get()
return Promise.resolve(Boolean(deleted)) return Promise.resolve(Boolean(deleted))

View file

@ -1,5 +1,4 @@
import { eq } from 'drizzle-orm' import { eq, and } from 'drizzle-orm'
import type { Database } from 'bun:sqlite'
import { db, sqlite } from './db-instance' import { db, sqlite } from './db-instance'
import { products } from './schema/products' import { products } from './schema/products'
@ -56,11 +55,11 @@ export type CreateProductInput =
export type UpdateProductPatch = Partial<CreateProductInput> export type UpdateProductPatch = Partial<CreateProductInput>
export type ProductsRepo = { export type ProductsRepo = {
getProducts: () => Promise<Product[]> getProducts: (enterpriseId: number) => Promise<Product[]>
getProductById: (id: number) => Promise<Product | null> getProductById: (id: number, enterpriseId: number) => Promise<Product | null>
createProduct: (input: CreateProductInput) => Promise<Product> createProduct: (input: CreateProductInput, enterpriseId: number) => Promise<Product>
updateProduct: (id: number, patch: UpdateProductPatch) => Promise<Product | null> updateProduct: (id: number, patch: UpdateProductPatch, enterpriseId: number) => Promise<Product | null>
deleteProduct: (id: number) => Promise<boolean> deleteProduct: (id: number, enterpriseId: number) => Promise<boolean>
} }
function getOrCreateDrug(name: string): number { function getOrCreateDrug(name: string): number {
@ -151,8 +150,8 @@ function setCompositions(productId: number, comps: CompositionInput[]) {
export function createProductsRepo(): { repo: ProductsRepo } { export function createProductsRepo(): { repo: ProductsRepo } {
const repo: ProductsRepo = { const repo: ProductsRepo = {
getProducts() { getProducts(enterpriseId) {
const rows = db.select().from(products).all() const rows = db.select().from(products).where(eq(products.enterpriseId, enterpriseId)).all()
return Promise.resolve( return Promise.resolve(
rows.map((r) => { rows.map((r) => {
const distributor = fetchDistributor(r.distributorId) const distributor = fetchDistributor(r.distributorId)
@ -165,8 +164,8 @@ export function createProductsRepo(): { repo: ProductsRepo } {
) )
}, },
getProductById(id) { getProductById(id, enterpriseId) {
const row = db.select().from(products).where(eq(products.id, id)).get() const row = db.select().from(products).where(and(eq(products.id, id), eq(products.enterpriseId, enterpriseId))).get()
if (!row) return Promise.resolve(null) if (!row) return Promise.resolve(null)
const distributor = fetchDistributor(row.distributorId) const distributor = fetchDistributor(row.distributorId)
const unit = fetchUnit(row.unitId) const unit = fetchUnit(row.unitId)
@ -176,7 +175,7 @@ export function createProductsRepo(): { repo: ProductsRepo } {
}) })
}, },
createProduct(input) { createProduct(input, enterpriseId) {
const result = sqlite.transaction(() => { const result = sqlite.transaction(() => {
const unitId = getOrCreateUnit(input.unit_name) const unitId = getOrCreateUnit(input.unit_name)
@ -196,6 +195,7 @@ export function createProductsRepo(): { repo: ProductsRepo } {
unitsPerStrip: input.units_per_strip ?? null, unitsPerStrip: input.units_per_strip ?? null,
hideProductFromPublic: input.hide_product_from_public ?? false, hideProductFromPublic: input.hide_product_from_public ?? false,
hidePriceFromPublic: input.hide_price_from_public ?? false, hidePriceFromPublic: input.hide_price_from_public ?? false,
enterpriseId,
}) })
.returning() .returning()
.get() .get()
@ -214,8 +214,8 @@ export function createProductsRepo(): { repo: ProductsRepo } {
}) })
}, },
updateProduct(id, patch) { updateProduct(id, patch, enterpriseId) {
const existing = db.select().from(products).where(eq(products.id, id)).get() const existing = db.select().from(products).where(and(eq(products.id, id), eq(products.enterpriseId, enterpriseId))).get()
if (!existing) return Promise.resolve(null) if (!existing) return Promise.resolve(null)
sqlite.transaction(() => { sqlite.transaction(() => {
@ -236,7 +236,7 @@ export function createProductsRepo(): { repo: ProductsRepo } {
if (patch.hide_price_from_public !== undefined) setData.hidePriceFromPublic = patch.hide_price_from_public if (patch.hide_price_from_public !== undefined) setData.hidePriceFromPublic = patch.hide_price_from_public
if (Object.keys(setData).length > 0) { if (Object.keys(setData).length > 0) {
db.update(products).set(setData).where(eq(products.id, id)).run() db.update(products).set(setData).where(and(eq(products.id, id), eq(products.enterpriseId, enterpriseId))).run()
} }
if (patch.compositions) { if (patch.compositions) {
@ -244,7 +244,7 @@ export function createProductsRepo(): { repo: ProductsRepo } {
} }
})() })()
const updated = db.select().from(products).where(eq(products.id, id)).get()! const updated = db.select().from(products).where(and(eq(products.id, id), eq(products.enterpriseId, enterpriseId))).get()!
const distributor = fetchDistributor(updated.distributorId) const distributor = fetchDistributor(updated.distributorId)
const unit = fetchUnit(updated.unitId) const unit = fetchUnit(updated.unitId)
@ -254,9 +254,9 @@ export function createProductsRepo(): { repo: ProductsRepo } {
}) })
}, },
deleteProduct(id) { deleteProduct(id, enterpriseId) {
db.delete(productCompositions).where(eq(productCompositions.productId, id)).run() db.delete(productCompositions).where(eq(productCompositions.productId, id)).run()
const deleted = db.delete(products).where(eq(products.id, id)).returning({ id: products.id }).get() const deleted = db.delete(products).where(and(eq(products.id, id), eq(products.enterpriseId, enterpriseId))).returning({ id: products.id }).get()
return Promise.resolve(Boolean(deleted)) return Promise.resolve(Boolean(deleted))
}, },
} }

View file

@ -1,4 +1,4 @@
import { eq } from 'drizzle-orm' import { eq, and } from 'drizzle-orm'
import { db, sqlite } from './db-instance' import { db, sqlite } from './db-instance'
import { stockBatches } from './schema/stockBatches' import { stockBatches } from './schema/stockBatches'
@ -34,11 +34,11 @@ export type CreateStockBatchInput = {
export type UpdateStockBatchPatch = Partial<CreateStockBatchInput> export type UpdateStockBatchPatch = Partial<CreateStockBatchInput>
export type StockBatchesRepo = { export type StockBatchesRepo = {
getStockBatches: () => Promise<StockBatch[]> getStockBatches: (enterpriseId: number) => Promise<StockBatch[]>
getStockBatchById: (id: number) => Promise<StockBatch | null> getStockBatchById: (id: number, enterpriseId: number) => Promise<StockBatch | null>
createStockBatch: (input: CreateStockBatchInput) => Promise<StockBatch> createStockBatch: (input: CreateStockBatchInput, enterpriseId: number) => Promise<StockBatch>
updateStockBatch: (id: number, patch: UpdateStockBatchPatch) => Promise<StockBatch | null> updateStockBatch: (id: number, patch: UpdateStockBatchPatch, enterpriseId: number) => Promise<StockBatch | null>
deleteStockBatch: (id: number) => Promise<boolean> deleteStockBatch: (id: number, enterpriseId: number) => Promise<boolean>
} }
function fetchProduct(productId: number): { id: number; name: string; brand: string } | null { function fetchProduct(productId: number): { id: number; name: string; brand: string } | null {
@ -78,22 +78,22 @@ function toStockBatch(row: typeof stockBatches.$inferSelect): StockBatch {
export function createStockBatchesRepo(): { repo: StockBatchesRepo } { export function createStockBatchesRepo(): { repo: StockBatchesRepo } {
const repo: StockBatchesRepo = { const repo: StockBatchesRepo = {
getStockBatches() { getStockBatches(enterpriseId) {
const rows = db.select().from(stockBatches).all() const rows = db.select().from(stockBatches).where(eq(stockBatches.enterpriseId, enterpriseId)).all()
return Promise.resolve(rows.map(toStockBatch)) return Promise.resolve(rows.map(toStockBatch))
}, },
getStockBatchById(id) { getStockBatchById(id, enterpriseId) {
const row = db.select().from(stockBatches).where(eq(stockBatches.id, id)).get() const row = db.select().from(stockBatches).where(and(eq(stockBatches.id, id), eq(stockBatches.enterpriseId, enterpriseId))).get()
return Promise.resolve(row ? toStockBatch(row) : null) return Promise.resolve(row ? toStockBatch(row) : null)
}, },
createStockBatch(input) { createStockBatch(input, enterpriseId) {
const result = sqlite.transaction(() => { const result = sqlite.transaction(() => {
if (input.is_default) { if (input.is_default) {
db.update(stockBatches) db.update(stockBatches)
.set({ isDefault: false }) .set({ isDefault: false })
.where(eq(stockBatches.productId, input.product_id)) .where(and(eq(stockBatches.productId, input.product_id), eq(stockBatches.enterpriseId, enterpriseId)))
.run() .run()
} }
@ -109,6 +109,7 @@ export function createStockBatchesRepo(): { repo: StockBatchesRepo } {
distributorId: input.distributor_id ?? null, distributorId: input.distributor_id ?? null,
quantity: input.quantity ?? 0, quantity: input.quantity ?? 0,
isDefault: input.is_default ?? false, isDefault: input.is_default ?? false,
enterpriseId,
}) })
.returning() .returning()
.get() .get()
@ -119,15 +120,15 @@ export function createStockBatchesRepo(): { repo: StockBatchesRepo } {
return Promise.resolve(toStockBatch(result)) return Promise.resolve(toStockBatch(result))
}, },
updateStockBatch(id, patch) { updateStockBatch(id, patch, enterpriseId) {
const existing = db.select().from(stockBatches).where(eq(stockBatches.id, id)).get() const existing = db.select().from(stockBatches).where(and(eq(stockBatches.id, id), eq(stockBatches.enterpriseId, enterpriseId))).get()
if (!existing) return Promise.resolve(null) if (!existing) return Promise.resolve(null)
sqlite.transaction(() => { sqlite.transaction(() => {
if (patch.is_default) { if (patch.is_default) {
db.update(stockBatches) db.update(stockBatches)
.set({ isDefault: false }) .set({ isDefault: false })
.where(eq(stockBatches.productId, patch.product_id ?? existing.productId)) .where(and(eq(stockBatches.productId, patch.product_id ?? existing.productId), eq(stockBatches.enterpriseId, enterpriseId)))
.run() .run()
} }
@ -143,16 +144,16 @@ export function createStockBatchesRepo(): { repo: StockBatchesRepo } {
if (patch.is_default !== undefined) setData.isDefault = patch.is_default if (patch.is_default !== undefined) setData.isDefault = patch.is_default
if (Object.keys(setData).length > 0) { if (Object.keys(setData).length > 0) {
db.update(stockBatches).set(setData).where(eq(stockBatches.id, id)).run() db.update(stockBatches).set(setData).where(and(eq(stockBatches.id, id), eq(stockBatches.enterpriseId, enterpriseId))).run()
} }
})() })()
const updated = db.select().from(stockBatches).where(eq(stockBatches.id, id)).get()! const updated = db.select().from(stockBatches).where(and(eq(stockBatches.id, id), eq(stockBatches.enterpriseId, enterpriseId))).get()!
return Promise.resolve(toStockBatch(updated)) return Promise.resolve(toStockBatch(updated))
}, },
deleteStockBatch(id) { deleteStockBatch(id, enterpriseId) {
const deleted = db.delete(stockBatches).where(eq(stockBatches.id, id)).returning({ id: stockBatches.id }).get() const deleted = db.delete(stockBatches).where(and(eq(stockBatches.id, id), eq(stockBatches.enterpriseId, enterpriseId))).returning({ id: stockBatches.id }).get()
return Promise.resolve(Boolean(deleted)) return Promise.resolve(Boolean(deleted))
}, },
} }

View file

@ -1,4 +1,4 @@
import { eq } from 'drizzle-orm' import { eq, and } from 'drizzle-orm'
import { db } from './db-instance' import { db } from './db-instance'
import { storageSpaces } from './schema/storageSpacesSchema' import { storageSpaces } from './schema/storageSpacesSchema'
@ -22,11 +22,11 @@ export type CreateStorageSpaceInput = {
export type UpdateStorageSpacePatch = Partial<CreateStorageSpaceInput> export type UpdateStorageSpacePatch = Partial<CreateStorageSpaceInput>
export type StorageSpacesRepo = { export type StorageSpacesRepo = {
getStorageSpaces: () => Promise<StorageSpace[]> getStorageSpaces: (enterpriseId: number) => Promise<StorageSpace[]>
getStorageSpaceById: (id: number) => Promise<StorageSpace | null> getStorageSpaceById: (id: number, enterpriseId: number) => Promise<StorageSpace | null>
createStorageSpace: (input: CreateStorageSpaceInput) => Promise<StorageSpace> createStorageSpace: (input: CreateStorageSpaceInput, enterpriseId: number) => Promise<StorageSpace>
updateStorageSpace: (id: number, patch: UpdateStorageSpacePatch) => Promise<StorageSpace | null> updateStorageSpace: (id: number, patch: UpdateStorageSpacePatch, enterpriseId: number) => Promise<StorageSpace | null>
deleteStorageSpace: (id: number) => Promise<boolean> deleteStorageSpace: (id: number, enterpriseId: number) => Promise<boolean>
} }
function toStorageSpace(row: { function toStorageSpace(row: {
@ -50,21 +50,25 @@ export function createStorageSpacesRepo(): {
} { } {
const repo: StorageSpacesRepo = { const repo: StorageSpacesRepo = {
getStorageSpaces() { getStorageSpaces(enterpriseId) {
const rows = db.select().from(storageSpaces).all() const rows = db
.select()
.from(storageSpaces)
.where(eq(storageSpaces.enterpriseId, enterpriseId))
.all()
return Promise.resolve(rows.map(toStorageSpace)) return Promise.resolve(rows.map(toStorageSpace))
}, },
getStorageSpaceById(id) { getStorageSpaceById(id, enterpriseId) {
const row = db const row = db
.select() .select()
.from(storageSpaces) .from(storageSpaces)
.where(eq(storageSpaces.id, id)) .where(and(eq(storageSpaces.id, id), eq(storageSpaces.enterpriseId, enterpriseId)))
.get() .get()
return Promise.resolve(row ? toStorageSpace(row) : null) return Promise.resolve(row ? toStorageSpace(row) : null)
}, },
createStorageSpace(input) { createStorageSpace(input, enterpriseId) {
const created = db const created = db
.insert(storageSpaces) .insert(storageSpaces)
.values({ .values({
@ -72,13 +76,14 @@ export function createStorageSpacesRepo(): {
description: input.description ?? null, description: input.description ?? null,
aliases: serializeStringArrJson(input.aliases), aliases: serializeStringArrJson(input.aliases),
imageUrls: serializeStringArrJson(input.image_urls), imageUrls: serializeStringArrJson(input.image_urls),
enterpriseId,
}) })
.returning() .returning()
.get() .get()
return Promise.resolve(toStorageSpace(created)) return Promise.resolve(toStorageSpace(created))
}, },
updateStorageSpace(id, patch) { updateStorageSpace(id, patch, enterpriseId) {
const updated = db const updated = db
.update(storageSpaces) .update(storageSpaces)
.set({ .set({
@ -91,16 +96,16 @@ export function createStorageSpacesRepo(): {
? { imageUrls: serializeStringArrJson(patch.image_urls) } ? { imageUrls: serializeStringArrJson(patch.image_urls) }
: {}), : {}),
}) })
.where(eq(storageSpaces.id, id)) .where(and(eq(storageSpaces.id, id), eq(storageSpaces.enterpriseId, enterpriseId)))
.returning() .returning()
.get() .get()
return Promise.resolve(updated ? toStorageSpace(updated) : null) return Promise.resolve(updated ? toStorageSpace(updated) : null)
}, },
deleteStorageSpace(id) { deleteStorageSpace(id, enterpriseId) {
const deleted = db const deleted = db
.delete(storageSpaces) .delete(storageSpaces)
.where(eq(storageSpaces.id, id)) .where(and(eq(storageSpaces.id, id), eq(storageSpaces.enterpriseId, enterpriseId)))
.returning({ id: storageSpaces.id }) .returning({ id: storageSpaces.id })
.get() .get()
return Promise.resolve(Boolean(deleted)) return Promise.resolve(Boolean(deleted))

View file

@ -0,0 +1,24 @@
import { useEffect, type ReactNode } from "react";
import { useWhoAmI, useAuthStore } from "./auth";
export function AuthGate({ children }: { children: ReactNode }) {
const { data, isLoading } = useWhoAmI();
const setUser = useAuthStore((s) => s.setUser);
const setLoading = useAuthStore((s) => s.setLoading);
useEffect(() => {
setLoading(isLoading);
}, [isLoading, setLoading]);
useEffect(() => {
if (!isLoading) {
if (data) {
setUser(data.staff, data.enterprise);
} else {
setUser(null, null);
}
}
}, [data, isLoading, setUser]);
return <>{children}</>;
}

View file

@ -0,0 +1,48 @@
import { create } from "zustand";
import { trpc } from "./trpc";
export interface AuthStaff {
id: number;
name: string;
username: string;
isPasswordResetNeeded: boolean;
}
export interface AuthEnterprise {
id: number;
name: string;
type: string;
}
interface AuthState {
staff: AuthStaff | null;
enterprise: AuthEnterprise | null;
isLoading: boolean;
setUser: (
staff: AuthStaff | null,
enterprise: AuthEnterprise | null,
) => void;
setLoading: (loading: boolean) => void;
logout: () => void;
}
export const useAuthStore = create<AuthState>((set) => ({
staff: null,
enterprise: null,
isLoading: true,
setUser: (staff, enterprise) =>
set({ staff, enterprise, isLoading: false }),
setLoading: (isLoading) => set({ isLoading }),
logout: () => set({ staff: null, enterprise: null, isLoading: false }),
}));
export function useWhoAmI() {
return trpc.auth.whoAmI.useQuery(undefined, {
retry: false,
refetchOnWindowFocus: false,
});
}
export function useLogin() {
return trpc.auth.login.useMutation();
}

View file

@ -8,3 +8,6 @@ export * from './hooks/drugInfo'
export * from './hooks/units' export * from './hooks/units'
export * from './hooks/stockBatches' export * from './hooks/stockBatches'
export { trpc } from './trpc' export { trpc } from './trpc'
export { useAuthStore, useWhoAmI, useLogin } from './auth'
export type { AuthStaff, AuthEnterprise } from './auth'
export { AuthGate } from './auth-gate'

View file

@ -9,6 +9,8 @@ export function createTrpcClient(baseUrl: string) {
links: [ links: [
httpBatchLink({ httpBatchLink({
url: `${baseUrl}/trpc`, url: `${baseUrl}/trpc`,
fetch: (url, options) =>
fetch(url, { ...options, credentials: "include" }),
}), }),
], ],
}) })