Add mailbox APIs

This commit is contained in:
Skylar Ittner 2025-11-21 15:39:03 -07:00
parent 766b0ebf45
commit af50b961b7
3 changed files with 351 additions and 1 deletions

View File

@ -128,6 +128,100 @@ PostalPoint uses the Jimp library version 1.6 for creating and manipulating imag
* `isKiosk()`: Returns a boolean to indicate if PostalPoint is running in kiosk mode. * `isKiosk()`: Returns a boolean to indicate if PostalPoint is running in kiosk mode.
#### Mailboxes
Note: Functions will throw an error if a mailbox number is not valid (must be alphanumeric).
Use `boxNumberValid(number)` to validate user input and avoid errors.
`global.apis.mailboxes.`:
* `FormPS1583`: PS Form 1583 object. See docs/FormPS1583.md
* `boxNumberValid(number)`: Returns true if the mailbox number is an acceptable format, false if it isn't. Does not check if the box actually exists, merely if the number is acceptable to use as a mailbox number.
* `async getList()`: Get the list of mailboxes and boxholders as an array of objects (see below)
* `async addDaysToMailbox(boxNumber, days = 0, months = 0)`: Add a number of days or months to a mailbox's expiration.
* `async setMailboxExpirationDate(boxNumber, date)`: Set the box expiration to a specific JavaScript Date object, or a UNIX timestamp (in seconds).
* `async createMailbox(number, size, notes)`: Create a new mailbox number with the specified box size. Throws an error if the box number is already in use.
* `async editMailbox(oldNumber, newNumber, newSize = null)`: Change the number and/or size of a mailbox while preserving the boxholders and packages associated. If only changing size, set oldNumber and newNumber to the same value.
* `async deleteMailbox(number)`: Delete a mailbox. Throws an error if the mailbox has boxholders attached.
* `async closeMailbox(number)`: Close a mailbox by removing the boxholders and marking it as vacant. Boxholder PS Form 1583 records are archived per USPS regulations.
* `async mailboxExists(number)`: Returns true if the mailbox number exists, false if it doesn't.
* `async addOrUpdateBoxholder(boxNumber, info)`: Modify or add a boxholder to a mailbox. `info` is the boxholder structure below. If the `uuid` given already belongs to a boxholder, their info is updated with what you supply. Otherwise, the info is added as a new boxholder.
* `async removeBoxholder(boxNumber, uuid)`: Remove a boxholder by their UUID, and archive their PS Form 1583 data per USPS regulations.
* `async get1583(boxNumber, uuid, archiveNumber = false)`: Get the FormPS1583 object for a boxholder by UUID. If `archiveNumber` is true, returns the form for a deleted boxholder from the archive.
* `async set1583(boxNumber, uuid, formps1583)`: Set the FormPS1583 object for a boxholder by UUID.
* `async getMailboxProducts()`: Get a list of merchandise items that are usable for mailbox renewals. See below.
##### Mailbox getList() output
```js
[{
num: "123", // Box number as string
expires: 1234567890, // UNIX timestamp (in seconds) or false if box vacant
size: "2", // Box size, 1-10
notes: "", // Notes for mailbox, not currently shown in Mailbox Manager UI but may be used in the future
barcode: "", // Unique barcode for the mailbox, for future use
renewalMerchID: "", // Merchandise item ID used for autorenewing this mailbox
isBusiness: false, // True if the box is for a business, false if for personal use
names: [], // Array of boxholders, see Boxholder structure below
packages: [], // Array of packages awaiting pickup, see below
vacant: false // True if the box is currently vacant, else false
}]
```
##### Boxholder structure
Unless noted, all fields are strings and default to an empty string if data not available.
```js
{
name: [bizname, fname, mname, lname].filter(Boolean).join(" "),
fname: "", // First name
mname: "", // Middle name
lname: "", // Last name
email: "", // Email
phone: "", // Phone
uuid: "", // Customer UUID
bizname: "", // Business name
street1: "", // Street address
city: "", // City
state: "", // Two-character state
zipcode: "", // ZIP or postal code
country: "", // Two-character country code
primary: true // True if the primary (first) boxholder, false if an additional authorized mail recipient
}
```
##### Mailbox getList()[].packages format
```js
{
tracking: tracking ?? "[Untracked]", // Package tracking number
finalized: true, // True if package check-in is finished and shelf tag/mailbox slips printed, false if not finalized
available_date: Date(), // The date and time the package was checked in
tag: "" // Unique number assigned to the package and printed on shelf tags, scanned by employee when customer picks up package
}
```
##### Mailbox merchandise item format (getMailboxProducts)
These fields are returned as entered in the Merchandise Admin user interface or as shown in the merchandise CSV export.
```js
{
id: "", // Unique ID for this entry in the merchandise table
name: "", // Merch item name
category: "", // Merch item category
price: 0.0, // Sale price in dollars
cost: 0.0, // Merchandise cost in dollars (likely not used for mailboxes)
barcode: "", // Barcode/UPC (likely not used for mailboxes)
tax: 0.0, // Sales tax rate
rentaldays: 30, // Number of days this item adds to a mailbox (mutually exclusive with rentalmonths)
rentalmonths: 1, // Number of months (mutually exclusive with rentaldays)
boxsize: "1" // Mailbox size tier, 1-10
}
```
#### Point of Sale #### Point of Sale
`global.apis.pos.`: `global.apis.pos.`:

256
docs/FormPS1583.md Normal file
View File

@ -0,0 +1,256 @@
# FormPS1583 object
```javascript
export class FormPS1583 {
constructor() {
this.formRevision = LATEST_FORM_REVISION; // Currently "June2024"
this.pmbOpenedDate = new Date();
this.pmbClosedDate = null;
this.cmraStreetAddress = getSetting("origin_street1");
this.pmbNumber = "";
this.cmraZIP = getSetting("origin_zip");
var cmraZIPData = getZIP(this.cmraZIP);
if (cmraZIPData) {
this.cmraCity = cmraZIPData.city;
this.cmraState = cmraZIPData.state;
} else {
this.cmraCity = getSetting("origin_city", "");
this.cmraState = getSetting("origin_state", "");
}
this.serviceTypeBusiness = false; // true for business PMB, false for residential
this.applicant = {
firstName: "",
lastName: "",
middleName: "",
phone: "",
email: "",
streetAddress: "",
city: "",
state: "",
zip: "",
country: "",
courtProtected: false,
photoID: {
name: "",
number: "",
issuer: "",
expirationDate: null,
type: null // "DL/ID", "UniformedService", "USAccess", "USUni",
// "Passport", "Matricula", "NEXUS",
// "CertOfNaturalization", "USPermResident"
},
addressID: {
name: "",
streetAddress: "",
city: "",
state: "",
zip: "",
country: "",
type: null, // "DL/ID", "Lease", "Mortgage", "Insurance", "VehicleReg", "Voter"
expirationDate: null // Optional currently but must be kept current - Oct 2025
}
};
this.authorizedIndividual = {
firstName: "",
lastName: "",
middleName: "",
phone: "",
email: "",
streetAddress: "",
city: "",
state: "",
zip: "",
country: "",
photoID: {
name: "",
number: "",
issuer: "",
expirationDate: null,
type: null // "DL/ID", "UniformedService", "USAccess", "USUni",
// "Passport", "Matricula", "NEXUS",
// "CertOfNaturalization", "USPermResident"
},
addressID: {
name: "",
streetAddress: "",
city: "",
state: "",
zip: "",
country: "",
type: null, // "DL/ID", "Lease", "Mortgage", "Insurance", "VehicleReg", "Voter"
expirationDate: null // Optional currently but must be kept current - Oct 2025
}
};
this.mailTransferredTo = {
streetAddress: "",
city: "",
state: "",
zip: "",
country: "",
phone: "",
email: ""
};
this.business = {
name: "",
type: "",
streetAddress: "",
city: "",
state: "",
zip: "",
country: "",
phone: "",
placeOfRegistration: ""
};
this.additionalRecipients = []; // Array of strings containing names
this.applicantSignature = ""; // PNG image data URI
this.applicantSignatureDate = null;
this.cmraSignature = ""; // PNG image data URI
this.cmraSignatureDate = null;
this.hasForwardingAddress = false;
}
getTermsAndConditions() {
return DEFAULT_TERMS_CONDITIONS[this.formRevision];
}
getApplicantForwardingAddress() {
if (this.mailTransferredTo.streetAddress != "") {
return new Address(null,
[this.applicant.firstName, this.applicant.lastName].filter(Boolean).join(" "),
this.business.name ?? "",
this.mailTransferredTo.streetAddress,
"",
this.mailTransferredTo.zip,
this.mailTransferredTo.city,
this.mailTransferredTo.state,
this.mailTransferredTo.country ?? "US",
this.mailTransferredTo.phone ?? "",
this.mailTransferredTo.email ?? ""
);
}
return new Address(null,
[this.applicant.firstName, this.applicant.lastName].filter(Boolean).join(" "),
this.business.name ?? "",
this.applicant.streetAddress,
"",
this.applicant.zip,
this.applicant.city,
this.applicant.state,
this.applicant.country ?? "US",
this.applicant.phone ?? "",
this.applicant.email ?? ""
);
}
getFormFields() {
var fields = FORM_FIELDS[this.formRevision];
function getNestedValue(obj, path) {
return path.split('.').reduce((o, key) => (o ? o[key] : ""), obj);
}
var outfields = [];
var groupheading = {};
var groupfields = [];
for (var prop in fields) {
if (fields[prop].t == "heading") {
if (groupfields.length > 0) {
groupheading.fields = groupfields;
outfields.push(groupheading);
groupfields = [];
}
groupheading = {
heading: fields[prop].l,
groupid: fields[prop].group ?? null,
fields: []
};
}
fields[prop].n = prop;
fields[prop].v = getNestedValue(this, prop);
if (typeof fields[prop].v == "undefined" || fields[prop].v == null) {
fields[prop].v = "";
}
if (fields[prop].t == "date") {
if (fields[prop].v instanceof Date) {
// Cancel out the timezone in the date object
// If we don't do this, the dates will be subtracted by one day each time we load
// https://stackoverflow.com/a/17329571
fields[prop].v.setTime(fields[prop].v.getTime() + fields[prop].v.getTimezoneOffset() * 60 * 1000);
}
fields[prop].v = formatTimestamp("Y-m-d", fields[prop].v);
if (fields[prop].v == "1969-12-31" || fields[prop].v == "1970-01-01") {
fields[prop].v = "";
}
}
if (fields[prop].t == "select" && typeof fields[prop].b == "boolean") {
fields[prop].v = fields[prop].v ? "true" : "";
}
if (fields[prop].t != "heading") {
groupfields.push(fields[prop]);
}
}
if (groupfields != []) {
groupheading.fields = groupfields;
outfields.push(groupheading);
}
return outfields;
}
static fromHTMLFormData(formdata, revision = LATEST_FORM_REVISION) {
var f = new FormPS1583();
function setNestedValue(obj, path, value) {
const keys = path.split('.');
const lastKey = keys.pop();
const target = keys.reduce((o, key) => {
if (o[key] === undefined)
o[key] = {};
return o[key];
}, obj);
if (typeof FORM_FIELDS[revision][path].b == "boolean") {
target[lastKey] = (value == "true" || value == true);
} else {
target[lastKey] = value;
}
}
for (var prop in formdata) {
setNestedValue(f, prop, formdata[prop]);
}
return f;
}
static fromJSON(o) {
var f = new FormPS1583();
f.formRevision = o.formRevision ?? LATEST_FORM_REVISION;
f.pmbOpenedDate = new Date(o.pmbOpenedDate);
f.pmbClosedDate = o.pmbClosedDate ? new Date(o.pmbClosedDate) : null;
f.cmraStreetAddress = o.cmraStreetAddress;
f.pmbNumber = o.pmbNumber;
f.cmraCity = o.cmraCity;
// snip, see constructor for full data structure
return f;
}
toJSON() {
return {
formRevision: this.formRevision,
pmbOpenedDate: this.pmbOpenedDate,
pmbClosedDate: this.pmbClosedDate,
cmraStreetAddress: this.cmraStreetAddress,
pmbNumber: this.pmbNumber,
cmraCity: this.cmraCity,
// snip, see constructor for full data structure
};
}
/**
* Render this form to PDF
* @returns PDF bytes
*/
async getPDF() {
// snip, it draws the form contents onto a PDF using the pdf-lib library
// If you really want to see how, email us for the code
return await document.save();
}
}
```

View File

@ -154,7 +154,7 @@ exports.init = function () {
"paidFormatted": "$22.34", "paidFormatted": "$22.34",
"due": -12.14, // If negative, is the amount of change owed to the customer instead "due": -12.14, // If negative, is the amount of change owed to the customer instead
"dueFormatted": "$12.14" "dueFormatted": "$12.14"
} };
}, },
checkoutSavedMethod: async function ({customerID, paymentMethodID, amount}) { checkoutSavedMethod: async function ({customerID, paymentMethodID, amount}) {
// Same as checkout() except using a payment method already on file. // Same as checkout() except using a payment method already on file.