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

routes/equipment.ts
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:

routes/customers.ts
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:

routes/rentals.ts
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 wroteWhat 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.


On this page