API
Three createCRUD routers in one navigation group, with tenant isolation and a manager-only delete.
Three routers, one per resource. Two things here reach the dashboard directly: the router's function name becomes the module name, and .group() becomes the navigation section.
Equipment
import { Router } from "@/core/http/router.ts";
import { createCRUD } from "@/core/utils/createCRUD.ts";
import {
equipmentInputSchema,
equipmentModel,
equipmentSchema,
} from "@/schemas/rental/equipment.ts";
import { withTenant } from "@plugins/Huruf-Tech/thunder-core/utils/withTenant.ts";
export default new Router("/api", function equipment(router) {
createCRUD({
router,
schema: equipmentSchema,
insertSchema: equipmentInputSchema,
model: equipmentModel,
}, {
isolationFields: async (req) => {
const { tenant } = await withTenant(req);
return { tenant: tenant._id };
},
});
}).group("Rental Desk");Customers
Identical in shape - only the schemas and the name change:
export default new Router("/api", function customers(router) {
createCRUD({
router,
schema: customerSchema,
insertSchema: customerInputSchema,
model: customerModel,
}, {
isolationFields: async (req) => {
const { tenant } = await withTenant(req);
return { tenant: tenant._id };
},
});
}).group("Rental Desk");All three routers share .group("Rental Desk"). That is deliberate - a group is a shared label, so the three modules land under one parent at /:tenant/rental-desk/.... See Controlling the UI From the Backend.
Rentals
This one stamps createdBy from the session, and hands del over to a manager-only handler:
import { Router } from "@/core/http/router.ts";
import { createCRUD } from "@/core/utils/createCRUD.ts";
import { paramsAsJson } from "@/core/http/utils.ts";
import { Response } from "@/core/http/response.ts";
import { rentalInputSchema, rentalModel, rentalSchema } from "@/schemas/rental/rental.ts";
import { withTenant } from "@plugins/Huruf-Tech/thunder-core/utils/withTenant.ts";
import { ObjectId } from "mongodb";
import z from "zod";
export default new Router("/api", function rentals(router) {
createCRUD({
router,
schema: rentalSchema,
insertSchema: rentalInputSchema,
// after creation, only the status may change
updateSchema: rentalInputSchema.pick({ status: true }),
model: rentalModel,
}, {
isolationFields: async (req, action) => {
const { tenant, user } = await withTenant(req);
// stamp the author on create; scope every other action to the tenant
if (action === "create") {
return { tenant: tenant._id, createdBy: user._id };
}
return { tenant: tenant._id };
},
// replaced below with a cancel-instead-of-delete handler
disable: { del: true },
});
// DELETE /rentals/api/:id → cancel, don't destroy
router.del("/:id", function cancelRental() {
const $params = z.object({ id: z.string() });
const $return = z.object({ cancelled: z.boolean() });
return {
shape: () => ({ params: $params, return: $return }),
handler: async (req) => {
const { id } = $params.parse(paramsAsJson(req));
const { tenant } = await withTenant(req);
await rentalModel.updateOne(
{ _id: new ObjectId(id), tenant: tenant._id },
{ $set: { status: "cancelled" } },
);
return Response.statusCode(200).json({ cancelled: true });
},
};
});
}).group("Rental Desk");Three Decisions That Change the UI
| What you wrote | What the dashboard does |
|---|---|
function rentals(router) | the module is called rentals everywhere in the frontend |
.group("Rental Desk") | all three modules share one sidebar section at /:tenant/rental-desk/ |
updateSchema: …pick({ status: true }) | the edit form shows only the status field; the create form still shows everything |
disable: { del: true } | the generated delete action disappears from the list page's action bar |
cancelRental is a new route, and thunder-core denies every route that no policy grants. It will return 403 until you add it - which is the next step.
Named functions are not a style preference here. function rentals(router) and function cancelRental() are the permission identifiers - rentals.cancelRental is the exact string you will grant. See Access Control.