diff --git a/functions/airtable.js b/functions/airtable.js
index 513963a..05a4e7c 100644
--- a/functions/airtable.js
+++ b/functions/airtable.js
@@ -298,6 +298,10 @@ async function getBulkOrder(records) {
failedToLookup.push(item);
return 0;
}
+ if (!_.has(itemsByHouseholdSize[item], householdSize)) {
+ failedToLookup.push([item, householdSize]);
+ return 0;
+ }
return itemsByHouseholdSize[item][householdSize];
};
@@ -318,12 +322,12 @@ async function getBulkOrder(records) {
);
if (failedToLookup.length !== 0) {
- // throw Error(`Failed to get item by household size for: ${_.join(_.uniq(failedToLookup))}`);
console.error(
`Failed to get item by household size for: ${_.join(
_.uniq(failedToLookup)
)}`
);
+ throw Error(`Failed to get item by household size for: ${_.join(_.uniq(failedToLookup))}`);
}
return itemToNumRequested;
@@ -350,6 +354,8 @@ async function getItemToNumAvailable(deliveryDate) {
*/
async function getAllRoutes(deliveryDate) {
const allRoutes = await getRecordsWithFilter(BULK_DELIVERY_ROUTES_TABLE, { deliveryDate });
+ // We are no longer using separate shopping volunteers!
+ /*
const routesWithoutShopper = _.filter(allRoutes, ([, fields]) => {
return (
fields.shoppingVolunteer === null || fields.shoppingVolunteer.length !== 1
@@ -362,6 +368,7 @@ async function getAllRoutes(deliveryDate) {
}));
throw new Error(msg);
}
+ */
return allRoutes;
}
@@ -381,12 +388,7 @@ function getTicketsForRoute([, fields]) {
* @param {[string, Object, Object][]} allRoutes Bulk delivery route records.
*/
async function getTicketsForRoutes(allRoutes) {
- return _.sortBy(
- await Promise.all(_.flatMap(allRoutes, getTicketsForRoute)),
- ([, fields]) => {
- return fields.ticketID;
- }
- );
+ return await Promise.all(_.flatMap(allRoutes, getTicketsForRoute));
}
class ReconciledOrder {
@@ -420,18 +422,9 @@ class ReconciledOrder {
}
bulkPurchasedItemsByGroup() {
- const groups = _.groupBy(_.toPairs(this.provided), ([item]) => {
+ return _.groupBy(_.toPairs(this.provided), ([item]) => {
return this.itemToCategory[item].category;
});
- const sorted = _.fromPairs(_.map(_.toPairs(groups), ([category, items]) => {
- return [
- category,
- _.sortBy(items, (item) => {
- return _.toNumber(this.itemToCategory[item[0]].order);
- })
- ];
- }));
- return sorted;
}
/**
@@ -467,6 +460,20 @@ class ReconciledOrder {
// @ts-ignore eslint doesn't understand array structures I guess?
return missingItems.concat(customItems);
}
+
+ getWarehouseItems() {
+ const fields = this.intakeRecord[1];
+ const warehouseItems = fields.warehouseSpecialtyItems;
+ if (!warehouseItems) {
+ return [];
+ }
+ return _.map(
+ _.split(warehouseItems, ','),
+ (item) => {
+ return { item: _.trim(item), quantity: null };
+ }
+ );
+ }
}
/**
@@ -485,7 +492,12 @@ async function reconcileOrders(deliveryDate, allRoutes) {
})
);
- const intakeRecords = await getTicketsForRoutes(allRoutes);
+ const intakeRecords = _.sortBy(
+ await getTicketsForRoutes(allRoutes),
+ ([, fields]) => {
+ return fields.ticketID;
+ }
+ );
const itemToNumAvailable = await getItemToNumAvailable(deliveryDate);
diff --git a/functions/schema.js b/functions/schema.js
index bb0ec0e..f77e5da 100644
--- a/functions/schema.js
+++ b/functions/schema.js
@@ -45,6 +45,7 @@ const INTAKE_SCHEMA = {
costCategory: 'cost_category',
foodOptions: 'Food Options',
otherItems: 'Other Items',
+ warehouseSpecialtyItems: 'Warehouse Specialty Items',
bulkRoute: 'Bulk Delivery Route',
deliveryVolunteerRecordID: 'Delivery Volunteer Record ID',
};
@@ -92,6 +93,7 @@ const ITEMS_BY_HOUSEHOLD_SIZE_SCHEMA = {
6: '6 Person(s)',
7: '7 Person(s)',
8: '8 Person(s)',
+ 9: '9 Person(s)',
};
const BULK_ORDER_SCHEMA = {
diff --git a/functions/scripts/email-bulk-delivery-volunteers.js b/functions/scripts/email-bulk-delivery-volunteers.js
index 3e0797f..d01caf9 100644
--- a/functions/scripts/email-bulk-delivery-volunteers.js
+++ b/functions/scripts/email-bulk-delivery-volunteers.js
@@ -5,16 +5,80 @@ const moment = require('moment');
const Mustache = require('mustache');
const yargs = require('yargs');
-const { getAllRoutes, getTicketsForRoutes, getRecordsWithFilter, BULK_DELIVERY_ROUTES_TABLE } = require('../airtable');
+// eslint-disable-next-line no-unused-vars
+const { getAllRoutes, getTicketsForRoutes, getRecordsWithFilter, reconcileOrders, BULK_DELIVERY_ROUTES_TABLE, ReconciledOrder } = require('../airtable');
const { googleMapsUrl, Email } = require('../messages');
-function getEmailTemplateParameters(route, tickets) {
- const ticketParameterMaps = tickets.map((ticket) => {
+// Each week we might need custom shoppers to do some "bulk purchases" if we
+// couldn't procure everything we needed. The "no route" section lets us ask
+// every custom shopper to get some bulk items and not sort them by tickets.
+const noRouteSection = {
+ items: []
+};
+
+/**
+ * Sometimes, we have "custom items" we have on hand and don't need shoppers
+ * to purchase, but they aren't represented in the Bulk Order table.
+ * @param {{ item: string, quantity: number | null }} param0 Custom item.
+ */
+const itemNeedsCustomShopping = ({ item }) => {
+ const lowered = _.lowerCase(item);
+ return !(
+ _.endsWith(lowered, 'art kit')
+ || _.endsWith(lowered, 'art kits')
+ || lowered.match(/\d\s*books?\s/) !== null
+ || _.endsWith(lowered, 'helmet')
+ || _.endsWith(lowered, 'helmets')
+ );
+};
+
+const getShoppingListTemplateParameters = (route, orders) => {
+ const allTickets = _.map(orders, (order) => {
+ const { ticketID, vulnerability, householdSize } = order.intakeRecord[1];
+ const conditions = _.concat([`household size ${householdSize}`], vulnerability);
+ const items = _.filter(order.getAdditionalItems(), itemNeedsCustomShopping);
+ return { ticketID, conditions: _.join(conditions, ', '), items };
+ });
+ const tickets = _.filter(allTickets, ({ items }) => {
+ return items.length > 0;
+ });
+ const params = {
+ tickets
+ };
+ if (!_.isEmpty(noRouteSection.items)) {
+ params.noRouteSection = noRouteSection;
+ }
+ return params;
+};
+
+/**
+ * Construct the mustache template parameter map.
+ * @param {Object} route route fields
+ * @param {ReconciledOrder[]} orders list of orders
+ */
+function getEmailTemplateParameters(route, orders) {
+ const ticketParameterMaps = orders.map((order) => {
+ const { intakeRecord: [, ticket,] } = order;
+ const additionalItems = order.getAdditionalItems();
+ const shoppingItems = _.map(
+ additionalItems,
+ ({ item, quantity }) => {
+ return `${quantity || ''} ${item}`.trim();
+ }
+ );
+ const warehouseSpecialtyItems = _.map(
+ order.getWarehouseItems(),
+ ({ item, quantity }) => {
+ return `${quantity || ''} ${item}`.trim();
+ }
+ );
return Object.assign({}, ticket, {
phoneNumberNumbersOnly: _.replace(ticket.phoneNumber, /[^0-9]/g, ''),
mapsUrl: googleMapsUrl(ticket.address),
vulnerabilities: _.join(ticket.vulnerability, ', '),
groceryList: _.join(ticket.foodOptions, ', '),
+ otherItems: _.join(shoppingItems, ', '),
+ warehouseSpecialtyItems: _.join(warehouseSpecialtyItems, ', '),
});
});
return {
@@ -22,13 +86,15 @@ function getEmailTemplateParameters(route, tickets) {
deliveryDateString: moment(route.deliveryDate).utc().format('MMMM Do'),
firstName: route.deliveryVolunteerName[0].split(' ')[0],
routeName: route.name,
- ticketIDs: _.join(_.map(tickets, (fields) => {
+ ticketIDs: _.join(_.map(orders, ({ intakeRecord: [, fields,]}) => {
return fields.ticketID;
}), ', '),
warehouseMapsUrl: googleMapsUrl('221 Glenmore Ave'),
arrivalTime: _.trim(route.arrivalTime),
- warehouseCoordinatorPhone: functions.config().bulk_ops_team.warehouse_coordinator.phone_number,
+ warehouseCoordinatorPhone1: functions.config().bulk_ops_team.warehouse_coordinator1.phone_number,
+ warehouseCoordinatorPhone2: functions.config().bulk_ops_team.warehouse_coordinator2.phone_number,
tickets: ticketParameterMaps,
+ shoppingList: getShoppingListTemplateParameters(route, orders),
};
}
@@ -51,18 +117,28 @@ async function main() {
await getRecordsWithFilter(BULK_DELIVERY_ROUTES_TABLE, { deliveryDate: argv.deliveryDate, name: argv.route })
) : await getAllRoutes(argv.deliveryDate);
+ const orders = await reconcileOrders(argv.deliveryDate, routes);
+
+ const ordersByKey = _.fromPairs(
+ _.map(orders, (order) => {
+ return [order.intakeRecord[0], order];
+ })
+ );
+
const templateParameterMaps = await Promise.all(_.map(routes, async (route) => {
const ticketRecords = await getTicketsForRoutes([route]);
- const ticketsFields = _.map(ticketRecords, ([, fields,]) => fields);
const [, routeFields,] = route;
- return getEmailTemplateParameters(routeFields, ticketsFields);
+ const orders = _.map(ticketRecords, ([ticketKey,]) => {
+ return ordersByKey[ticketKey];
+ });
+ return getEmailTemplateParameters(routeFields, orders);
}));
- const templateFilename = 'functions/templates/bulk-delivery-volunteer-email.md.mustache';
- const template = (await fs.promises.readFile(templateFilename)).toString('utf-8');
+ const emailTemplateFilename = 'functions/templates/bulk-delivery-volunteer-email.md.mustache';
+ const emailTemplate = (await fs.promises.readFile(emailTemplateFilename)).toString('utf-8');
const emails = _.map(templateParameterMaps, (view) => {
- const markdown = Mustache.render(template, view);
+ const markdown = Mustache.render(emailTemplate, view);
return new Email(markdown, {
to: view.to,
cc: 'operations+bulk@bedstuystrong.com',
@@ -86,5 +162,10 @@ async function main() {
main().then(
() => console.log('done')
).catch(
- (e) => console.error(e)
+ (e) => {
+ console.error(e);
+ if (e.response && e.response.body && e.response.body.errors) {
+ console.error(e.response.body.errors);
+ }
+ }
);
diff --git a/functions/scripts/email-bulk-shopping-volunteers.js b/functions/scripts/email-bulk-shopping-volunteers.js
deleted file mode 100644
index ea33abb..0000000
--- a/functions/scripts/email-bulk-shopping-volunteers.js
+++ /dev/null
@@ -1,138 +0,0 @@
-const functions = require('firebase-functions');
-const fs = require('fs');
-const _ = require('lodash');
-const moment = require('moment');
-const Mustache = require('mustache');
-const yargs = require('yargs');
-
-const { reconcileOrders, getAllRoutes } = require('../airtable');
-const { Email, googleMapsUrl } = require('../messages');
-
-async function main() {
- const { argv } = yargs
- .option('deliveryDate', {
- coerce: (x) => new Date(x),
- demandOption: true,
- describe: 'Date of scheduled delivery (yyyy-mm-dd format)',
- })
- .boolean('dryRun');
-
- // --------------------------------------------------------------------------
- // CUSTOMIZATION
-
- // Each week we might need custom shoppers to do some "bulk purchases" if we
- // couldn't procure everything we needed. The "no route" section lets us ask
- // every custom shopper to get some bulk items and not sort them by tickets.
- const noRouteSection = {
- items: []
- };
-
- /**
- * Sometimes, we have "custom items" we have on hand and don't need shoppers
- * to purchase, but they aren't represented in the Bulk Order table.
- * @param {{ item: string, quantity: number | null }} param0 Custom item.
- */
- const itemNeedsCustomShopping = ({ item }) => {
- return !(_.endsWith(item, 'art kit') || _.endsWith(item, 'art kits'));
- };
-
- // END CUSTOMIZATION
- // --------------------------------------------------------------------------
-
- const allRoutes = await getAllRoutes(argv.deliveryDate);
-
- const routesMissingShoppingVolunteer = _.filter(allRoutes, ([, fields]) => {
- return fields.shoppingVolunteer === null;
- });
- if (!_.isEmpty(routesMissingShoppingVolunteer)) {
- const msg = 'Some routes are missing a shopping volunteer';
- console.error(msg, routesMissingShoppingVolunteer);
- throw new Error(msg);
- }
-
- const routesByShopper = _.groupBy(allRoutes, ([, fields]) => {
- return fields.shoppingVolunteerEmail[0];
- });
-
- const orders = await reconcileOrders(argv.deliveryDate, allRoutes);
-
- const ordersByKey = _.fromPairs(
- _.map(orders, (order) => {
- return [order.intakeRecord[0], order];
- })
- );
-
- const templateParameterMaps = _.map(_.values(routesByShopper), (routes) => {
- const sortedRoutes = _.sortBy(routes, ([, fields]) => fields.name);
-
- const routeParameters = _.map(sortedRoutes, ([, fields]) => {
- const { name } = fields;
- const orders = _.sortBy(
- _.map(fields.intakeTickets, (ticketKey) => {
- return ordersByKey[ticketKey];
- }),
- (order) => {
- return order.intakeRecord[1].ticketID;
- }
- );
- const allTicketParameters = _.map(orders, (order) => {
- const { ticketID, vulnerability, householdSize } = order.intakeRecord[1];
- const conditions = _.concat([`household size ${householdSize}`], vulnerability);
- const items = _.filter(order.getAdditionalItems(), itemNeedsCustomShopping);
- return { ticketID, conditions: _.join(conditions, ', '), items };
- });
- const tickets = _.filter(allTicketParameters, ({ items }) => {
- return items.length > 0;
- });
- return { name, tickets };
- });
-
- const { shoppingVolunteerEmail, shoppingVolunteerName } = routes[0][1];
- const firstName = shoppingVolunteerName[0].split(' ')[0];
- const deliveryDateString = moment(argv.deliveryDate).utc().format('MMMM Do');
- const warehouseMapsUrl = googleMapsUrl('221 Glenmore Ave');
- const warehouseCoordinatorPhone = functions.config().bulk_ops_team.warehouse_coordinator.phone_number;
-
- return {
- to: shoppingVolunteerEmail,
- firstName,
- deliveryDateString,
- warehouseMapsUrl,
- warehouseCoordinatorPhone,
- noRouteSection: _.isEmpty(noRouteSection.items) ? null : noRouteSection,
- routes: routeParameters,
- };
- });
-
- const templateFilename = 'functions/templates/bulk-shopping-volunteer-email.md.mustache';
- const template = (await fs.promises.readFile(templateFilename)).toString('utf-8');
-
- const emails = _.map(templateParameterMaps, (view) => {
- const markdown = Mustache.render(template, view);
-
- return new Email(markdown, {
- to: view.to,
- cc: 'operations+bulk@bedstuystrong.com',
- replyTo: 'operations+bulk@bedstuystrong.com',
- subject: `[BSS Bulk Ordering] ${view.deliveryDateString} Delivery Prep and Instructions for ${view.firstName}`,
- });
- });
-
- if (argv.dryRun) {
- _.forEach(emails, (email) => {
- console.log('To:', email.render().to);
- console.log(email.render().text);
- });
- } else {
- await Promise.all(
- _.map(emails, (email) => {
- return email.send();
- })
- );
- }
- return null;
-}
-
-main()
- .then(() => console.log('done'))
- .catch((e) => console.error(e));
diff --git a/functions/scripts/generate-order-sheet.js b/functions/scripts/generate-order-sheet.js
index 2736dee..f807d98 100644
--- a/functions/scripts/generate-order-sheet.js
+++ b/functions/scripts/generate-order-sheet.js
@@ -133,6 +133,7 @@ const padOrder = (itemToNumRequested, bufferRatio) => {
return _.map(
itemToNumRequested,
([item, numRequested]) => {
+ console.log({ item, numRequested });
return [item, padOrderSize(numRequested)];
},
);
diff --git a/functions/scripts/generate-packing-slips.js b/functions/scripts/generate-packing-slips.js
index b525891..809adb3 100644
--- a/functions/scripts/generate-packing-slips.js
+++ b/functions/scripts/generate-packing-slips.js
@@ -12,6 +12,8 @@ const {
ReconciledOrder,
} = require('../airtable');
+const generalCategories = ['Non-perishable', 'Produce'];
+
/**
* Render one packing slip for this order.
* @param {ReconciledOrder} order Reconciled order
@@ -29,20 +31,24 @@ function renderPackingSlip(order, singleCategory, slipNumber) {
markdown += `**Delivery**: ${order.volunteer.Name}\n\n`;
markdown += `**Sheet**: ${slipNumber + 1}/3\n\n`;
- const categoryOrder = singleCategory
- ? [singleCategory]
- : ['Non-perishable', 'Produce', 'Last'];
+ const categorySet = singleCategory === 'General' ? generalCategories : [singleCategory];
const renderTable = (groups, categories) => {
- const columns = (categories.length === 1 && categories[0] === 'General') ? ([
- [categories[0], _.take(groups[categories[0]], _.ceil(groups[categories[0]].length / 2))],
- [`${categories[0]} (cont.)`, _.drop(groups[categories[0]], _.ceil(groups[categories[0]].length / 2))]
+ const categoryItems = _.sortBy(
+ _.concat(..._.map(categories, (category) => groups[category] || [])),
+ (item) => {
+ return _.toNumber(order.itemToCategory[item[0]].order);
+ }
+ );
+ const columns = (singleCategory === 'General') ? ([
+ [singleCategory, _.take(categoryItems, _.ceil(categoryItems.length / 2))],
+ [`${singleCategory} (cont.)`, _.drop(categoryItems, _.ceil(categoryItems.length / 2))]
]) : (_.map(categories, (category) => {
return [category, groups[category]];
}));
const numRows = _.max(
- _.map(columns, ([category, items]) => {
- return _.includes(categories, category) && items ? items.length : 0;
+ _.map(columns, ([, items]) => {
+ return items ? items.length : 0;
})
);
markdown += '| ';
@@ -66,38 +72,43 @@ function renderPackingSlip(order, singleCategory, slipNumber) {
}
markdown += '\n';
};
- renderTable(itemGroups, categoryOrder);
+ renderTable(itemGroups, categorySet);
+
+ if (singleCategory === 'General' && (!_.isNull(fields.otherItems) || !_.isNull(fields.warehouseItems) || !_.isEqual(order.provided, order.requested))) {
+ /**
+ * @param {[{ item: string, quantity: number | null }]} items List of
+ * items to purchase.
+ */
+ const renderOtherTable = (title, items) => {
+ const numCols = 2;
+ const numRows = _.ceil(items.length / 2.0);
+ const itemsDescendingLength = _.sortBy(items, ({ item }) => {
+ return -item.length;
+ });
+ markdown += `| ${title} |\n| --- |`;
+ for (var row = 0; row < numRows; row++) {
+ markdown += '\n|';
+ for (var col = 0; col < numCols; col++) {
+ const i = row + col * numRows;
+ if (i >= items.length) {
+ markdown += ' |';
+ } else {
+ markdown += ` ${itemsDescendingLength[i].quantity || ''} ${itemsDescendingLength[i].item} |`;
+ }
+ }
+ }
+ markdown += '\n';
+ };
- if (singleCategory === 'General' && (!_.isNull(fields.otherItems) || !_.isEqual(order.provided, order.requested))) {
const otherItems = order.getAdditionalItems();
if (otherItems.length > 0) {
markdown += '\n---\n';
-
- /**
- * @param {[{ item: string, quantity: number | null }]} items List of
- * items to purchase.
- */
- const renderOtherTable = (items) => {
- const numCols = 2;
- const numRows = _.ceil(items.length / 2.0);
- const itemsDescendingLength = _.sortBy(items, ({ item }) => {
- return -item.length;
- });
- markdown += '| Other |\n| --- |';
- for (var row = 0; row < numRows; row++) {
- markdown += '\n|';
- for (var col = 0; col < numCols; col++) {
- const i = row + col * numRows;
- if (i >= items.length) {
- markdown += ' |';
- } else {
- markdown += ` ${itemsDescendingLength[i].quantity || ''} ${itemsDescendingLength[i].item} |`;
- }
- }
- }
- markdown += '\n';
- };
- renderOtherTable(otherItems);
+ renderOtherTable('Other Items', otherItems);
+ }
+ const warehouseItems = order.getWarehouseItems();
+ if (warehouseItems.length > 0) {
+ markdown += '\n---\n';
+ renderOtherTable('Warehouse Items', warehouseItems);
}
}
@@ -127,7 +138,15 @@ async function savePackingSlips(orders) {
// Three sheets, one with Cleaning Bundle, one Fridge / Frozen, one with
// everything else.
const sheetCategories = ['General', 'Cleaning Bundle', 'Fridge / Frozen'];
+ const realOrderCategories = _.concat(_.slice(sheetCategories, 1), generalCategories);
const outPaths = await Promise.all(_.flatMap(orders, (order) => {
+ const orderCategories = _.keys(order.bulkPurchasedItemsByGroup());
+ const notIncludedCategories = _.filter(orderCategories, (category) => !_.includes(realOrderCategories, category));
+ if (!_.isEmpty(notIncludedCategories)) {
+ const msg = `Some item categories are not accounted for: ${_.join(notIncludedCategories, ', ')}`;
+ console.error(msg);
+ throw new Error(msg);
+ }
return _.map(sheetCategories, async (category, i) => {
const markdown = renderPackingSlip(order, category, i);
const stream = PDF.from.string(markdown);
diff --git a/functions/templates/bulk-delivery-volunteer-email.md.mustache b/functions/templates/bulk-delivery-volunteer-email.md.mustache
index 005f77b..7d0784b 100644
--- a/functions/templates/bulk-delivery-volunteer-email.md.mustache
+++ b/functions/templates/bulk-delivery-volunteer-email.md.mustache
@@ -1,74 +1,86 @@
Hi {{firstName}}!
-Thank you for volunteering to deliver groceries to our neighbors with Bed-Stuy
-Strong!
-
-We've assigned you Route {{routeName}} with the following tickets: {{ticketIDs}}
-
-### Overview
-
-This coming Saturday, please come to the Brooklyn Packers warehouse at **[221
-Glenmore Ave, Gate 4]({{{warehouseMapsUrl}}}) at {{arrivalTime}}** to pick up
-your deliveries. **Please do not park in front of the main driveway.** Find a
-spot to park, and then walk up to the warehouse to let Hanna and Francesca know
-that you've arrived. When your deliveries are all ready, you'll pull up closer
-and we'll bring the pallet with your deliveries to your car.
-
-If you are shopping for your own tickets, please do your shopping first (you'll
-also get an email about shopping), and get the items labeled and in your car,
-before coming to the warehouse.
-
-If you requested a separate shopping volunteer, please arrange to meet them at
-the store to pick up your additional items, with enough time to get to the
-warehouse by {{arrivalTime}}.
-
-The neighbors you're delivering to have confirmed their availability for
-1:30-4pm, but you'll call each of them before you leave the warehouse to get any
-last minute delivery details.
-
-You'll load your car with boxes/bags for the above ticket IDs, and then deliver
-them to the addresses below. Since there are perishables in the deliveries,
-you'll need to deliver them immediately after pickup. You may want to plan your
-route to Brooklyn Packers and then to the delivery locations in advance.
-
-If possible, we recommend printing this email out so you can mark tickets done
-as you complete them. Please also fill out the [Completion
-Form](https://airtable.com/shrvHf4k5lRo0I8F4) when you finish your deliveries.
-If any issues come up during your deliveries, or you are unable to deliver any
-of the boxes (because someone isn't home) contact Francesca at
-{{warehouseCoordinatorPhone}}. We'll help you redistribute the food to the
-community in another way.
-
-### Delivery Day Checklist
-
-- [ ] Shop for custom items (instructions sent separately) or meet your shopping
- teammate at an agreed-upon time and place.
-
-- [ ] Check in with Hanna or Francesca at the warehouse when you arrive. They'll
- let you know when your main food boxes and additional items are ready.
-
-While you're waiting:
-
-- [ ] Call the recipients of each ticket to confirm someone will be home to
- accept the delivery. (If they're not, please let Francesca or Hanna know --
- we'll use their items for someone else, and deliver to them another time.)
-- [ ] At the warehouse, you'll collect the following for each household (we'll
- give you more specific instructions day of):
- - [ ] Main food boxes (may be multiple per household)
- - [ ] Cleaning supplies
- - [ ] Custom items
- - [ ] Water
-- [ ] All boxes/bags will be labeled with ticket IDs. Please confirm all the
- ticket IDs match, and have your route number/name on them.
-- [ ] Put everything in your car
-- [ ] Check off each delivery below as you complete it
-- [ ] Fill out the [delivery completion
- form](https://airtable.com/shrvHf4k5lRo0I8F4) when you're done. If you
- didn't do any shopping, specify $0 Total Cost.
+Thank you again for volunteering to help our neighbors.
+
+**We've assigned you Route {{routeName}} with the following tickets. We've
+listed your tickets in delivery order: {{ticketIDs}}**
+
+**Please arrive at Brooklyn Packers this Saturday, {{deliveryDateString}} at
+{{arrivalTime}} with your custom items already purchased.**
+
+Check out the instructions below. If any issues come up day-of, contact this
+week's Dispatcher: Annika at {{warehouseCoordinatorPhone1}}.
+
+### Saturday Volunteer Instructions
+
+- [ ] **Get ready.** Please bring a pen or marker to the store for labeling
+ bags. We also recommend printing this if you can—it helps to mark off
+ tasks as they are completed.
+- [ ] **Go shopping for custom items.** See the Custom Item Shopping List below
+ for what to purchase. Sort the items into bags by household, and label
+ each bag with the corresponding ticket ID. (They can all be purchased on
+ one receipt).
+- [ ] **Come to the Brooklyn Packers warehouse for pre-packed groceries at
+ {{arrivalTime}}:** [221 Glenmore Ave, Gate 3, Brooklyn, NY
+ 11207]({{{warehouseMapsUrl}}})
+ - [ ] Find a spot to park (please do not park in front of the main driveway).
+ - [ ] Come to the warehouse and let us know you've arrived; the on-site
+ coordinator will give additional information and answer any questions
+ you might have.
+ - [ ] Hold tight - we'll let you know when your items are ready.
+- [ ] **While you're waiting,** call the recipients of each ticket to confirm
+ that someone will be home to accept the delivery. **If you can't reach
+ them,** let the dispatcher know and proceed with delivery. **If you reach
+ them but they won't be home,** let the dispatcher know and we will use
+ their groceries for someone else and deliver to them another time.
+- [ ] **When your items are ready,** pull up closer and we'll bring the pallet
+ to your car. You'll be given the following for each ticket (these are
+ provided at the warehouse and do not need to be purchased):
+ - [ ] Pre-packed groceries (may be multiple bags or boxes per household)
+ - [ ] Cleaning supplies
+ - [ ] Perishables bag
+ - [ ] Warehouse Specialty Items (e.g. Art kits, books, etc.)
+ - [ ] Water
+- [ ] **Please confirm that the ticket IDs on every box and bag match your route
+ number/name.**
+- [ ] **Load up your car.** We recommend arranging them by route, so the first
+ stop is the most accessible.
+- [ ] **Make your deliveries and check off each one as you complete it.** If a
+ neighbor gives you a donation, please accept it with gratitude! You can
+ enter the donation amount in the delivery completion form.
+- [ ] **When you're done, fill out the [Delivery Completion
+ Form](https://airtable.com/shrvHf4k5lRo0I8F4).** Please include:
+ - [ ] The total amount you spent
+ - [ ] Images of your receipt(s)
+ - [ ] A note that your reimbursement form is for **custom items shopping for
+ bulk purchasing households on {{deliveryDateString}}**
Thank you!
-----
+---
+{{#shoppingList}}
+### Custom Item Shopping List
+
+{{#noRouteSection}}
+#### No Route Number
+
+{{#items}}
+- [ ] {{{item}}}
+{{/items}}
+
+{{/noRouteSection}}
+
+{{#tickets}}
+**Ticket {{ticketID}}** ({{conditions}})
+
+{{#items}}
+- [ ] {{quantity}} {{item}}
+{{/items}}
+
+---
+{{/tickets}}
+
+{{/shoppingList}}
### Tickets (Route {{routeName}})
{{#tickets}}
@@ -82,9 +94,11 @@ Thank you!
**Vulnerabilities**: {{vulnerabilities}}
**Household Size**: {{householdSize}}
+**Language**: {{language}}
-**Grocery List**: {{groceryList}}
-**Custom Items**: {{otherItems}}
+**Pre-packed Groceries**: {{groceryList}}
+**Custom Shopping Items**: {{otherItems}}
+**Warehouse Specialty Items**: {{warehouseSpecialtyItems}}
{{#deliveryNotes}}**Notes for Delivery**: {{.}}
{{/deliveryNotes}}
----
diff --git a/functions/templates/bulk-shopping-volunteer-email.md.mustache b/functions/templates/bulk-shopping-volunteer-email.md.mustache
deleted file mode 100644
index d8425fe..0000000
--- a/functions/templates/bulk-shopping-volunteer-email.md.mustache
+++ /dev/null
@@ -1,60 +0,0 @@
-Hi {{firstName}}!
-
-Thank you again for volunteering to shop for these custom items!
-
-Please sort the items so that each ticket ID has its own bag or bags, and label
-each one of the bags with its corresponding **route number and ticket ID**.
-
-There are a variety of ways you can label the bags:
-
-1. Write the route number and ticket ID on a slip of paper or post-it note and
- staple or tape it to the bag.
-2. Write the route number and ticket ID on a slip of paper or post-it and just
- set it inside the bag with the groceries.
-3. Write the route number and ticket ID directly on the grocery bag(s) with a
- Sharpie or other pen/marker that won't smear or rub off.
-
-Please remember to bring at least one pen and some paper with you to the grocery
-store!
-
-If you're **shopping for your own tickets**, that's great, just make sure you
-can do your shopping and get to the warehouse on time (the arrival time will be
-in your delivery email instructions). Please wait until you've completed your
-deliveries to submit the reimbursement form.
-
-If you're **shopping for a delivery volunteer**, please coordinate with them to
-have them meet you at the store to pick up those items, with enough time for
-them to get to the warehouse. Submit the [reimbursement
-form](https://airtable.com/shrvHf4k5lRo0I8F4) in the usual way, with the total
-amount you spent and images of your receipt(s). Just pick one ticket ID from
-your list and enter that. Please add a note that your reimbursement form is for
-**custom items shopping for bulk purchasing households on
-{{deliveryDateString}}**.
-
-Thanks so much! Call Francesca at {{warehouseCoordinatorPhone}} with any
-questions!
-
-# Shopping List
-
-{{#noRouteSection}}
-## No Route Number
-
-{{#items}}
- - [ ] {{{item}}}
-{{/items}}
-
-{{/noRouteSection}}
-
-{{#routes}}
-## Route {{name}}
-
-{{#tickets}}
-**Ticket {{ticketID}}** ({{conditions}})
-
-{{#items}}
- - [ ] {{quantity}} {{item}}
-{{/items}}
-
----
-{{/tickets}}
-{{/routes}}
\ No newline at end of file