Add Tender
addTender opens a tender โ a public procurement auction that collects supplier offers for a purchase. It's the act that starts competitive bidding: you give it a title (and optionally a status, deadline, description, and organization), and it creates the tender in the default Open state, stamped with the authenticated user as createdBy. Use it whenever the organization wants to buy through competitive quotes rather than a fixed supplier.
Imports
Throughout this tutorial the framework imports use the "lesan" alias, matching the app's deno.json. In your own project import from @hemedani/lesan (npm/Bun) or jsr:@hemedani/lesan (Deno) instead.
The validator (addTender.val.ts)โ
title is the only required field besides the activeRoleId mixin. status is optional but constrained to the real status enum via tender_status_emums โ the source of truth for ["Open", "Awarded", "Closed"] defined in models/tender.ts. deadline and description are optional strings, and organization an optional ObjectId. The get projection is selectStruct("tender", 1).
import { object, objectIdValidation, optional, string } from "lesan";
import { selectStruct } from "../../../mod.ts";
import { activeRoleMixin } from "@lib";
import { tender_status_emums } from "@model";
export const addTenderValidator = () => {
return object({
set: object({
...activeRoleMixin,
title: string(),
status: optional(tender_status_emums),
deadline: optional(string()),
description: optional(string()),
organization: optional(objectIdValidation),
}),
get: selectStruct("tender", 1),
});
};
The implementation (addTender.fn.ts)โ
After stripActiveRole removes the activeRoleId, organization and status are pulled out of the rest. The current user comes from the request context via coreApp.contextFns.getContextModel().
Two relations get wired up:
organization(only if sent) โ stores the organization'sPuresnapshot inside the tender and pushes the tender into the organization's inversetendersback-reference.createdByโ always set touser._id, with the user's inversecreatedTendersback-reference. This is the audit trail: every tender knows who opened it.
Finally insertOne stores the document with status: status ?? "Open" โ if the client didn't pick a status, it defaults to Open, the only state offers can be added to.
import { type ActFn, type TInsertRelations, ObjectId } from "lesan";
import { coreApp, tender } from "../../../mod.ts";
import { stripActiveRole } from "@lib";
import type { MyContext } from "@lib";
import type { tender_relations } from "@model";
export const addTenderFn: ActFn = async (body) => {
const { set, get } = body.details;
const { organization, status, ...rest } = stripActiveRole(set);
const { user }: MyContext = coreApp.contextFns.getContextModel() as MyContext;
const relations: TInsertRelations<typeof tender_relations> = {};
organization &&
(relations.organization = {
_ids: new ObjectId(organization as string),
relatedRelations: { tenders: true },
});
relations.createdBy = {
_ids: user._id,
relatedRelations: { createdTenders: true },
};
return await tender.insertOne({
doc: { ...rest, status: status ?? "Open" },
relations,
projection: get,
});
};
In the workflowโ
addTender opens the tendering arc of the Finance chapter. The lifecycle after it: suppliers post offers with addOffer (only while Open), the buyer closes it with award (status flips to Awarded), and it can be browsed with getTenders. A tender is distinct from a budget line โ the budgetLine model reserves money, while the tender model collects supplier offers; they meet when the awarded tender's price informs the purchase order that goes through the approval workflow.
Run itโ
With the server on http://localhost:1380 and a valid token:
curl -X POST http://localhost:1380/lesan \
-H "Content-Type: application/json" \
-H "token: <your-token>" \
-d '{
"model": "tender",
"act": "addTender",
"details": {
"set": {
"activeRoleId": "ghost-role",
"title": "TSH Kit Supply Tender",
"status": "Open",
"organization": "<organization-id>"
},
"get": {
"_id": 1,
"title": 1,
"status": 1
}
}
}'
status is optional โ omit it and the tender is still created as Open. Keep the returned _id; it's the tenderId you'll pass to addOffer and award.
Errors & fixesโ
addTender throws no custom errors of its own โ the validator and the ODM guard everything. The cases worth knowing:
statusmust be one ofOpen,Awarded,Closedโ thetender_status_emumsenum rejects anything else (e.g."open"lowercase). The response is{ success: false }with the failed value in the message.- Missing
titleโ it's the only required field besidesactiveRoleId. Add it toset. organizationmust be a valid ObjectId โobjectIdValidationrejects malformed IDs. Pass the real_idstring from theorganizationcollection.