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