diff --git a/scripts/generateSeeds.ts b/scripts/generateSeeds.ts new file mode 100644 index 00000000..f368dd3f --- /dev/null +++ b/scripts/generateSeeds.ts @@ -0,0 +1,784 @@ +#!/usr/bin/env tsx +/** + * Script to generate N seeds for a given location with LA Youth Count PDF format + * Usage: npm run generate-seeds -- [templateKey] + * + * Examples: + * npm run generate-seeds -- "My Friends Place" 10 + * npm run generate-seeds -- "My Friends Place" 10 metro + * npm run generate-seeds -- 692f9100056e7a6957d0f0a2 50 east + * + * Available templates: metro, east, antelope, south, west + * Default template: metro + */ + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; +import { createRequire } from 'module'; + +// Get current directory +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Create require from server directory to access server's node_modules +const serverRequire = createRequire(path.join(__dirname, '../server/package.json')); +const QRCode = serverRequire('qrcode'); +const PDFDocument = serverRequire('pdfkit'); +const mongoose = serverRequire('mongoose'); + +// ===== Location Template Configuration ===== + +interface LocationInfo { + name: string; + address: string; + hoursEn: string; + hoursEs: string; +} + +interface LocationTemplate { + headerEn: string; + headerEs: string; + subheaderEn: string; + subheaderEs: string; + warningEn: string; + warningEs: string; + locations: LocationInfo[]; +} + +// Load templates from external JSON file +function loadTemplates(): Record { + const templatesPath = path.join(__dirname, 'seed_templates.json'); + try { + const templatesContent = fs.readFileSync(templatesPath, 'utf-8'); + return JSON.parse(templatesContent); + } catch (error) { + throw new Error(`Failed to load seed templates from ${templatesPath}: ${error instanceof Error ? error.message : error}`); + } +} + +// ===== PDF Generation Helper Functions ===== + +function createOutputDirectory(): string { + const outputDir = path.join(__dirname, 'seeds'); + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + return outputDir; +} + +function generateTimestampFilename(locationName: string, outputDir: string): string { + const now = new Date(); + const timestamp = [ + now.getFullYear(), + String(now.getMonth() + 1).padStart(2, '0'), + String(now.getDate()).padStart(2, '0'), + String(now.getHours()).padStart(2, '0'), + String(now.getMinutes()).padStart(2, '0'), + String(now.getSeconds()).padStart(2, '0') + ].join(''); + const sanitizedLocationName = locationName.replace(/[^a-z0-9]/gi, '-').toLowerCase(); + const filename = `la-youth-count-seeds-${sanitizedLocationName}-${timestamp}.pdf`; + return path.join(outputDir, filename); +} + +async function generateQRCodeBuffer(surveyCode: string, qrSize: number): Promise { + // Encode only the referral code (no URL) so QR codes work across any deployment + const qrDataUrl = await QRCode.toDataURL(surveyCode, { + width: qrSize, + margin: 1, + errorCorrectionLevel: 'M', + }); + return Buffer.from(qrDataUrl.split(',')[1], 'base64'); +} + +function renderLocationsTable( + doc: any, + locations: LocationInfo[], + tableY: number, + margin: number, + contentWidth: number, + useSpanish: boolean = false +): void { + const tableHeight = 230; + + // Draw outer border (without top) + doc.lineWidth(2) + .rect(margin, tableY, contentWidth, tableHeight) + .stroke(); + + // Column configuration + const colPadding = 10; + const colGap = 10; + const col1X = margin + colPadding; + const col1Width = (contentWidth - colGap - colPadding * 2) / 2; + const col2X = margin + col1Width + colGap + colPadding; + const col2Width = col1Width; + + // Determine how many locations per column + const locationsPerColumn = Math.ceil(locations.length / 2); + const leftLocations = locations.slice(0, locationsPerColumn); + const rightLocations = locations.slice(locationsPerColumn); + + // Render left column + let leftY = tableY + 16; + for (const location of leftLocations) { + const hours = useSpanish ? location.hoursEs : location.hoursEn; + + doc.fontSize(11.5).font('Helvetica-Bold').fillColor('#1a1a1a'); + let textHeight = doc.heightOfString(location.name, { width: col1Width - colPadding }); + doc.text(location.name, col1X, leftY, { + width: col1Width - colPadding, + align: 'left' + }); + leftY += textHeight + 5; + + doc.fontSize(11.5).font('Helvetica'); + textHeight = doc.heightOfString(location.address, { width: col1Width - colPadding }); + doc.text(location.address, col1X, leftY, { + width: col1Width - colPadding, + align: 'left' + }); + leftY += textHeight + 5; + + textHeight = doc.heightOfString(hours, { width: col1Width - colPadding }); + doc.text(hours, col1X, leftY, { + width: col1Width - colPadding, + align: 'left' + }); + leftY += textHeight + 15; + } + + // Render right column + let rightY = tableY + 16; + for (const location of rightLocations) { + const hours = useSpanish ? location.hoursEs : location.hoursEn; + + doc.fontSize(11.5).font('Helvetica-Bold').fillColor('#1a1a1a'); + let textHeight = doc.heightOfString(location.name, { width: col2Width - colPadding }); + doc.text(location.name, col2X, rightY, { + width: col2Width - colPadding, + align: 'left' + }); + rightY += textHeight + 5; + + doc.fontSize(11.5).font('Helvetica'); + textHeight = doc.heightOfString(location.address, { width: col2Width - colPadding }); + doc.text(location.address, col2X, rightY, { + width: col2Width - colPadding, + align: 'left' + }); + rightY += textHeight + 5; + + textHeight = doc.heightOfString(hours, { width: col2Width - colPadding }); + doc.text(hours, col2X, rightY, { + width: col2Width - colPadding, + align: 'left' + }); + rightY += textHeight + 15; + } +} + +async function addEnglishPage(doc: any, surveyCode: string, template: LocationTemplate): Promise { + const pageWidth = doc.page.width; + const pageHeight = doc.page.height; + const margin = 30; + const contentWidth = pageWidth - margin * 2; + + let currentY = margin; + + // Header with title and QR code + const qrSize = 120; + const qrX = pageWidth - margin - qrSize; + const titleWidth = qrX - margin - 10; + + // Title + doc.fontSize(21) + .font('Helvetica-Bold') + .fillColor('#1a1a1a') + .text('LOS ANGELES YOUTH COUNT', margin, currentY, { + width: titleWidth, + align: 'left' + }); + + currentY += 40; + + doc.fontSize(15) + .font('Helvetica-Bold') + .text('UNSHELTERED YOUTH COUNT COUPON', margin, currentY, { + width: titleWidth, + align: 'left' + }); + + // QR Code - ACTUAL QR CODE INSTEAD OF PLACEHOLDER + const qrBuffer = await generateQRCodeBuffer(surveyCode, qrSize); + doc.image(qrBuffer, qrX, margin, { + width: qrSize, + height: qrSize + }); + + // Display survey code below QR code as blue hyperlink + const qrCodeTextY = margin + qrSize + 5; + doc.fontSize(12) + .font('Helvetica-Bold') + .fillColor('#1a1a1a ') + .text(surveyCode, qrX, qrCodeTextY, { + width: qrSize, + align: 'center', + link: `https://respondent-driven-sampling.azurewebsites.net/apply-referral?surveyCode=${surveyCode}`, + underline: false + }); + + currentY = margin + qrSize + 20; + + // Intro paragraph + const introText = 'Young people under the age of 25 who are sleeping outside or in RVs, cars, or other locations not meant for human habitation are needed for a survey from January 5th through 31st. '; + const introBoldText = 'PLEASE BRING THIS COUPON TO A LOCATION BELOW.'; + + const fullIntroText = introText + introBoldText; + const introHeight = doc.heightOfString(fullIntroText, { + width: contentWidth, + align: 'justify' + }); + + doc.fontSize(11.5) + .font('Helvetica') + .text(introText, margin, currentY, { + width: contentWidth, + align: 'justify', + continued: true + }) + .font('Helvetica-Bold') + .text(introBoldText); + + currentY += introHeight + 35; + + // Incentive + const incentiveText = '$20 Visa cards will be provided to those who complete the survey!'; + const incentiveHeight = doc.heightOfString(incentiveText, { + width: contentWidth + }); + + doc.fontSize(11.5) + .font('Helvetica-Bold') + .text(incentiveText, margin, currentY, { + width: contentWidth, + align: 'left' + }); + + currentY += incentiveHeight + 20; + + // Hub sites header box + const hubBoxY = currentY; + const hubBoxHeight = 75; + + doc.rect(margin, hubBoxY, contentWidth, hubBoxHeight) + .fillAndStroke('#f9f9f9', '#1a1a1a'); + + doc.fillColor('#1a1a1a') + .fontSize(14.5) + .font('Helvetica-Bold') + .text(template.headerEn, margin + 15, hubBoxY + 12, { + width: contentWidth - 30, + align: 'center' + }); + + // Calculate centered position for the hub sites text with blue link + const hubSitesFullText = template.subheaderEn; + const hubSitesFullTextWidth = doc.widthOfString(hubSitesFullText); + const hubSitesStartX = margin + 15 + (contentWidth - 30 - hubSitesFullTextWidth) / 2; + + doc.fontSize(10.75) + .font('Helvetica') + .fillColor('#1a1a1a') + .text('View all Hub Sites in LA County at ', hubSitesStartX, hubBoxY + 35, { + continued: true + }) + .fillColor('#1a1a1a') + .text('youthcount.org/map', { + link: 'https://youthcount.org/map', + underline: true + }); + + doc.fontSize(9.25) + .font('Helvetica') + .fillColor('#1a1a1a') + .text(template.warningEn, margin + 15, hubBoxY + 55, { + width: contentWidth - 30, + align: 'center' + }); + + currentY = hubBoxY + hubBoxHeight; + + // Locations table + const tableY = currentY; + renderLocationsTable(doc, template.locations, tableY, margin, contentWidth, false); + + currentY = tableY + 230 + 15; + + // Uber section + const uberBoxY = currentY; + const uberBoxHeight = 92; + + doc.rect(margin, uberBoxY, contentWidth, uberBoxHeight) + .fillAndStroke('#f9f9f9', '#000000'); + + // Add left border accent + doc.lineWidth(4) + .moveTo(margin, uberBoxY) + .lineTo(margin, uberBoxY + uberBoxHeight) + .stroke('#000000'); + + doc.fillColor('#1a1a1a') + .fontSize(11.5) + .font('Helvetica-Bold') + .text('$10 off your Uber rides to and from a hub site with this voucher', margin + 12, uberBoxY + 12, { + width: contentWidth - 24, + align: 'left' + }); + + let uberY = uberBoxY + 32; + + const voucherLineText = '• UBER VOUCHER: RKRBSSFQJFS https://r.uber.com/rkrbssfqjfs'; + doc.fontSize(11.5).font('Helvetica'); + const voucherHeight = doc.heightOfString(voucherLineText, { width: contentWidth - 24 }); + + doc.text('• UBER VOUCHER: ', margin + 12, uberY, { + width: contentWidth - 24, + align: 'left', + continued: true + }) + .font('Courier-Bold') + .text('RKRBSSFQJFS ', { continued: true }) + .font('Helvetica') + .fillColor('#1a1a1a') + .text('https://r.uber.com/rkrbssfqjfs', { + link: 'https://r.uber.com/rkrbssfqjfs', + underline: true + }); + + uberY += voucherHeight + 8; + + const uberDetailsText = '• Receive $10 off of 2 Uber trips to and from any designated Hub sites during surveying times using the voucher code. Visit youthcount.org/uber for more details.'; + doc.fontSize(11.5).font('Helvetica').fillColor('#1a1a1a'); + const detailsHeight = doc.heightOfString(uberDetailsText, { width: contentWidth - 24 }); + + doc.text(uberDetailsText, margin + 12, uberY, { + width: contentWidth - 24, + align: 'left' + }); + + currentY = uberBoxY + uberBoxHeight + 12; + + // Footer + doc.moveTo(margin, currentY) + .lineTo(pageWidth - margin, currentY) + .lineWidth(2) + .stroke('#dddddd'); + + currentY += 10; + + doc.fontSize(11.5) + .font('Helvetica') + .fillColor('#1a1a1a') + .text('Data will be used to report to Housing and Urban Development (HUD). More info at ', margin, currentY, { + width: contentWidth, + align: 'left', + continued: true + }) + .fillColor('#1a1a1a') + .text('youthcount.org', { + link: 'https://youthcount.org', + underline: true + }); +} + +async function addSpanishPage(doc: any, surveyCode: string, template: LocationTemplate): Promise { + const pageWidth = doc.page.width; + const pageHeight = doc.page.height; + const margin = 30; + const contentWidth = pageWidth - margin * 2; + + let currentY = margin; + + // Header with title and QR code + const qrSize = 120; + const qrX = pageWidth - margin - qrSize; + const titleWidth = qrX - margin - 10; + + // Title + doc.fontSize(21) + .font('Helvetica-Bold') + .fillColor('#1a1a1a') + .text('LOS ANGELES YOUTH COUNT', margin, currentY, { + width: titleWidth, + align: 'left' + }); + + currentY += 40; + + doc.fontSize(15) + .font('Helvetica-Bold') + .text('CUPÓN DEL CONTEO DE JÓVENES SIN HOGAR', margin, currentY, { + width: titleWidth, + align: 'left' + }); + + // QR Code - ACTUAL QR CODE INSTEAD OF PLACEHOLDER + const qrBuffer = await generateQRCodeBuffer(surveyCode, qrSize); + doc.image(qrBuffer, qrX, margin, { + width: qrSize, + height: qrSize + }); + + // Display survey code below QR code as blue hyperlink + const qrCodeTextY = margin + qrSize + 5; + doc.fontSize(12) + .font('Helvetica-Bold') + .fillColor('#1a1a1a ') + .text(surveyCode, qrX, qrCodeTextY, { + width: qrSize, + align: 'center', + link: `https://respondent-driven-sampling.azurewebsites.net/apply-referral?surveyCode=${surveyCode}`, + underline: false + }); + + currentY = margin + qrSize + 20; + + // Intro paragraph + const introText = 'Se necesitan jóvenes menores de 25 años que estén durmiendo afuera o en vehículos recreativos, autos u otros lugares no destinados para la habitación humana para una encuesta del 5 al 31 de enero. '; + const introBoldText = '¡LLEVE ESTE CUPÓN A UNO DE LOS LUGARES INDICADOS ABAJO!'; + + const fullIntroText = introText + introBoldText; + const introHeight = doc.heightOfString(fullIntroText, { + width: contentWidth, + align: 'justify' + }); + + doc.fontSize(11.5) + .font('Helvetica') + .text(introText, margin, currentY, { + width: contentWidth, + align: 'justify', + continued: true + }) + .font('Helvetica-Bold') + .text(introBoldText); + + currentY += introHeight + 20; + + // Incentive + const incentiveText = '¡TARJETA VISA DE $20 después de completar una encuesta!'; + const incentiveHeight = doc.heightOfString(incentiveText, { + width: contentWidth + }); + + doc.fontSize(11.5) + .font('Helvetica-Bold') + .text(incentiveText, margin, currentY, { + width: contentWidth, + align: 'left' + }); + + currentY += incentiveHeight + 20; + + // Hub sites header box + const hubBoxY = currentY; + const hubBoxHeight = 75; + + doc.rect(margin, hubBoxY, contentWidth, hubBoxHeight) + .fillAndStroke('#f9f9f9', '#1a1a1a'); + + doc.fillColor('#1a1a1a') + .fontSize(14.5) + .font('Helvetica-Bold') + .text(template.headerEs, margin + 15, hubBoxY + 12, { + width: contentWidth - 30, + align: 'center' + }); + + // Calculate centered position for the hub sites text with blue link + const hubSitesFullTextEs = template.subheaderEs; + const hubSitesFullTextWidthEs = doc.widthOfString(hubSitesFullTextEs); + const hubSitesStartXEs = margin + 15 + (contentWidth - 30 - hubSitesFullTextWidthEs) / 2; + + doc.fontSize(10.75) + .font('Helvetica') + .fillColor('#1a1a1a') + .text('Vea los Centros (Hubs) en el Condado de Los Ángeles en ', hubSitesStartXEs, hubBoxY + 35, { + continued: true + }) + .fillColor('#1a1a1a') + .text('youthcount.org/map', { + link: 'https://youthcount.org/map', + underline: true + }); + + doc.fontSize(9.25) + .font('Helvetica') + .fillColor('#1a1a1a') + .text(template.warningEs, margin + 15, hubBoxY + 55, { + width: contentWidth - 30, + align: 'center' + }); + + currentY = hubBoxY + hubBoxHeight; + + // Locations table (Spanish version) + const tableY = currentY; + renderLocationsTable(doc, template.locations, tableY, margin, contentWidth, true); + + currentY = tableY + 230 + 15; + + // Uber section + const uberBoxY = currentY; + const uberBoxHeight = 92; + + doc.rect(margin, uberBoxY, contentWidth, uberBoxHeight) + .fillAndStroke('#f9f9f9', '#000000'); + + // Add left border accent + doc.lineWidth(4) + .moveTo(margin, uberBoxY) + .lineTo(margin, uberBoxY + uberBoxHeight) + .stroke('#000000'); + + doc.fillColor('#1a1a1a') + .fontSize(11.5) + .font('Helvetica-Bold') + .text('Ahorre $10 en sus viajes de Uber (ida y vuelta) con este código', margin + 12, uberBoxY + 12, { + width: contentWidth - 24, + align: 'left' + }); + + let uberY = uberBoxY + 32; + + const voucherLineText = '• CUPÓN DE UBER: RKRBSSFQJFS https://r.uber.com/rkrbssfqjfs'; + doc.fontSize(11.5).font('Helvetica'); + const voucherHeight = doc.heightOfString(voucherLineText, { width: contentWidth - 24 }); + + doc.text('• CUPÓN DE UBER: ', margin + 12, uberY, { + width: contentWidth - 24, + align: 'left', + continued: true + }) + .font('Courier-Bold') + .text('RKRBSSFQJFS ', { continued: true }) + .font('Helvetica') + .fillColor('#1a1a1a') + .text('https://r.uber.com/rkrbssfqjfs', { + link: 'https://r.uber.com/rkrbssfqjfs', + underline: true + }); + + uberY += voucherHeight + 8; + + const uberDetailsText = '• Reciba $10 de descuento en cada viaje de Uber (ida y vuelta) a cualquier centro (Hub) designado durante los horarios de encuesta usando el código. Más detalles en youthcount.org/uber.'; + doc.fontSize(11.5).font('Helvetica').fillColor('#1a1a1a'); + const detailsHeight = doc.heightOfString(uberDetailsText, { width: contentWidth - 24 }); + + doc.text(uberDetailsText, margin + 12, uberY, { + width: contentWidth - 24, + align: 'left' + }); + + currentY = uberBoxY + uberBoxHeight + 12; + + // Footer + doc.moveTo(margin, currentY) + .lineTo(pageWidth - margin, currentY) + .lineWidth(2) + .stroke('#dddddd'); + + currentY += 10; + + doc.fontSize(11.5) + .font('Helvetica') + .fillColor('#1a1a1a') + .text('Los datos se utilizarán para informar al Departamento de Vivienda y Desarrollo Urbano (HUD). Más info en ', margin, currentY, { + width: contentWidth, + align: 'left', + continued: true + }) + .fillColor('#1a1a1a') + .text('youthcount.org', { + link: 'https://youthcount.org', + underline: true + }); +} + +async function generatePDF(seeds: any[], locationName: string, templateKey: string = 'metro'): Promise { + const outputDir = createOutputDirectory(); + const filepath = generateTimestampFilename(locationName, outputDir); + + // Load and get the location template + const templates = loadTemplates(); + const template = templates[templateKey]; + if (!template) { + throw new Error(`Template "${templateKey}" not found. Available templates: ${Object.keys(templates).join(', ')}`); + } + + const doc = new PDFDocument({ + size: 'LETTER', + margin: 30, + autoFirstPage: false + }); + + const stream = fs.createWriteStream(filepath); + doc.pipe(stream); + + // Generate two-sided coupons (English + Spanish) for each seed + for (const seed of seeds) { + // Add English page + doc.addPage(); + await addEnglishPage(doc, seed.surveyCode, template); + + // Add Spanish page + doc.addPage(); + await addSpanishPage(doc, seed.surveyCode, template); + } + + doc.end(); + + await new Promise((resolve, reject) => { + stream.on('finish', resolve); + stream.on('error', reject); + }); + + console.log(`\n✓ PDF generated: ${filepath}`); + console.log(` Total pages: ${seeds.length * 2} (${seeds.length} English + ${seeds.length} Spanish)`); + console.log(` Using template: ${templateKey}`); +} + +// ===== Seed Generation Helper Functions ===== + +function isValidObjectId(identifier: string): boolean { + return mongoose.Types.ObjectId.isValid(identifier) && /^[0-9a-fA-F]{24}$/.test(identifier); +} + +async function findLocationByIdentifier(locationIdentifier: string, Location: any): Promise { + const isObjectId = isValidObjectId(locationIdentifier); + + let location; + if (isObjectId) { + console.log(`Looking up location with ObjectId: "${locationIdentifier}"...`); + location = await Location.findById(locationIdentifier); + } else { + console.log(`Looking up location with hubName: "${locationIdentifier}"...`); + location = await Location.findOne({ hubName: locationIdentifier }); + } + + if (!location) { + const idType = isObjectId ? 'ObjectId' : 'hubName'; + throw new Error(`Location with ${idType} "${locationIdentifier}" not found`); + } + + console.log(`Found location: ${location.hubName} (${location._id}) ✓\n`); + return location; +} + +async function createSeed(surveyCode: string, locationId: any, Seed: any, templateKey: string, index: number, total: number): Promise { + try { + const seed = await Seed.create({ + surveyCode, + locationObjectId: locationId, + isFallback: false, + templateKey, + }); + + console.log(` [${index + 1}/${total}] Created seed: ${seed.surveyCode} (${seed._id})`); + return seed; + } catch (error) { + console.error(` [${index + 1}/${total}] Failed to create seed:`, error); + throw error; + } +} + +async function generateSeedsForLocation( + location: any, + count: number, + Seed: any, + generateUniqueSurveyCode: () => Promise, + templateKey: string, +): Promise { + console.log(`Generating ${count} seed(s)...\n`); + const createdSeeds: any[] = []; + + for (let i = 0; i < count; i++) { + const surveyCode = await generateUniqueSurveyCode(); + const seed = await createSeed(surveyCode, location._id, Seed, templateKey, i, count); + createdSeeds.push(seed); + } + + return createdSeeds; +} + +function printSeedsSummary(seeds: any[], locationName: string): void { + console.log(`\n✓ Successfully generated ${seeds.length} seed(s) for location "${locationName}"`); + console.log('\nGenerated Survey Codes:'); + seeds.forEach((seed, index) => { + console.log(` ${index + 1}. ${seed.surveyCode}`); + }); +} + +async function generateSeeds(locationIdentifier: string, count: number, templateKey?: string): Promise { + const Location = (await import('../server/src/database/location/mongoose/location.model.js')).default; + const Seed = (await import('../server/src/database/seed/mongoose/seed.model.js')).default; + const { generateUniqueSurveyCode } = await import('../server/src/database/survey/survey.controller.js'); + const connectDB = (await import('../server/src/database/index.js')).default; + + try { + console.log('Connecting to database...'); + await connectDB(); + console.log('Connected to database ✓\n'); + + const location = await findLocationByIdentifier(locationIdentifier, Location); + const createdSeeds = await generateSeedsForLocation(location, count, Seed, generateUniqueSurveyCode, templateKey || 'metro'); + + printSeedsSummary(createdSeeds, location.hubName); + + console.log('\n📄 Generating PDF with QR codes (LA Youth Count format)...'); + await generatePDF(createdSeeds, location.hubName, templateKey); + } catch (error) { + console.error('\n✗ Error:', error instanceof Error ? error.message : error); + process.exit(1); + } finally { + await mongoose.connection.close(); + console.log('\nDatabase connection closed.'); + process.exit(0); + } +} + +// Parse command line arguments +const args = process.argv.slice(2); + +if (args.length < 2 || args.length > 3) { + const templates = loadTemplates(); + console.error('Usage: npm run generate-seeds -- [templateKey]'); + console.error(''); + console.error('Examples:'); + console.error(' npm run generate-seeds -- "My Friends Place" 10'); + console.error(' npm run generate-seeds -- 507f1f77bcf86cd799439011 100 metro'); + console.error(' npm run generate-seeds -- 692fc19f0d01f4b400e665d0 50 east'); + console.error(''); + console.error('Available templates:'); + Object.keys(templates).forEach(key => { + const template = templates[key]; + console.error(` - ${key}: ${template.headerEn}`); + }); + console.error(''); + console.error('Default template: metro'); + process.exit(1); +} + +const [locationIdentifier, countStr, templateKey] = args; +const count = parseInt(countStr, 10); + +if (isNaN(count) || count <= 0) { + console.error('Error: count must be a positive number'); + process.exit(1); +} + +// Run the script +generateSeeds(locationIdentifier, count, templateKey); diff --git a/scripts/seed_templates.json b/scripts/seed_templates.json new file mode 100644 index 00000000..a8d45fbe --- /dev/null +++ b/scripts/seed_templates.json @@ -0,0 +1,154 @@ +{ + "metro": { + "headerEn": "HOLLYWOOD AND SOUTH LA HUB SITES (SPA 4 and 6)", + "headerEs": "CENTROS (HUBS) DE HOLLYWOOD Y SOUTH LA (SPA 4 y 6)", + "subheaderEn": "View all Hub Sites in LA County at youthcount.org/map", + "subheaderEs": "Vea los Centros (Hubs) en el Condado de Los Ángeles en youthcount.org/map", + "warningEn": "TIMES LISTED BELOW ARE DESIGNATED SURVEYING HOURS. AGENCIES OPEN OUTSIDE OF THESE HOURS.", + "warningEs": "LOS HORARIOS QUE SE INDICAN SON LAS HORAS DESIGNADAS PARA LAS ENCUESTAS. LAS AGENCIAS ESTÁN ABIERTAS FUERA DE ESTOS HORARIOS.", + "locations": [ + { + "name": "EAST HOLLYWOOD: YP2F HQ", + "address": "4308 Burns Ave LA CA 90029", + "hoursEn": "SURVEYING: Tue and Thu 5:00 PM - 8:00 PM", + "hoursEs": "ENCUESTAS: Mar y Jue 5:00 PM - 8:00 PM" + }, + { + "name": "HOLLYWOOD: My Friends Place", + "address": "5850 Hollywood Blvd, LA CA 90028", + "hoursEn": "SURVEYING: Tue, Thu, Fri 9:30 AM - 3:30 PM", + "hoursEs": "ENCUESTAS: Mar, Jue, Vie 9:30 AM - 3:30 PM" + }, + { + "name": "SOUTH LA: Ruth's Place", + "address": "4775 S. Broadway Los Angeles 90007", + "hoursEn": "SURVEYING: Tue-Fri 10:00 AM - 4:00 PM", + "hoursEs": "ENCUESTAS: Mar-Vie 10:00 AM - 4:00 PM" + }, + { + "name": "HOLLYWOOD: LA LGBT Center", + "address": "1118 N. McCadden Pl. LA, CA 90038", + "hoursEn": "SURVEYING: Mon-Fri 10:00 AM - 6:00 PM (Closed Mon Jan 19th), SAT, SUN 9:00 AM - 1:00 PM", + "hoursEs": "ENCUESTAS: Lun-Vie 10:00 AM - 6:00 PM (Cerrado el lunes 19 de enero); Sab, Dom 9:00 AM - 1:00 PM" + }, + { + "name": "SOUTH LA: WATTS LABOR ACTION COMMUNITY", + "address": "958 E 108th Street, Los Angeles, CA 90059", + "hoursEn": "SURVEYING: Tue-Fri 9:00 AM - 3:30 PM", + "hoursEs": "ENCUESTAS: Mar-Vie 9:00 AM - 3:30 PM" + } + ] + }, + "sfv": { + "headerEn": "SAN FERNANDO VALLEY HUB SITES (SPA 2)", + "headerEs": "CENTROS (HUBS) DE SAN FERNANDO VALLEY (SPA 2)", + "subheaderEn": "View all Hub Sites in LA County at youthcount.org/map", + "subheaderEs": "Vea los Centros (Hubs) en el Condado de Los Ángeles en youthcount.org/map", + "warningEn": "TIMES LISTED BELOW ARE DESIGNATED SURVEYING HOURS. AGENCIES OPEN OUTSIDE OF THESE HOURS.", + "warningEs": "LOS HORARIOS QUE SE INDICAN SON LAS HORAS DESIGNADAS PARA LAS ENCUESTAS. LAS AGENCIAS ESTÁN ABIERTAS FUERA DE ESTOS HORARIOS.", + "locations": [ + { + "name": "PACOIMA: Volunteers Of America", + "address": "12502 Van Nuys Blvd., Suite 206 Pacoima", + "hoursEn": "SURVEYING: Tue-Fri 10:00 AM-4:00 PM", + "hoursEs": "ENCUESTAS: Mar-Vie 10:00 AM - 4:00 PM" + }, + { + "name": "VAN NUYS/NORTH HOLLYWOOD: The Village Family Services", + "address": "6801 Coldwater Canyon, North Hollywood", + "hoursEn": "SURVEYING: Tue-Fri 10:00 AM-5:00 PM, Sat 10:00 AM-4:00 PM", + "hoursEs": "ENCUESTAS: Mar-Vie 10:00 AM - 5:00 PM, Sab 10:00 AM - 4:00 PM" + } + ] + }, + "av": { + "headerEn": "ANTELOPE VALLEY HUB SITES (SPA 1)", + "headerEs": "CENTROS (HUBS) DE ANTELOPE VALLEY (SPA 1)", + "subheaderEn": "View all Hub Sites in LA County at youthcount.org/map", + "subheaderEs": "Vea los Centros (Hubs) en el Condado de Los Ángeles en youthcount.org/map", + "warningEn": "TIMES LISTED BELOW ARE DESIGNATED SURVEYING HOURS. AGENCIES OPEN OUTSIDE OF THESE HOURS.", + "warningEs": "LOS HORARIOS QUE SE INDICAN SON LAS HORAS DESIGNADAS PARA LAS ENCUESTAS. LAS AGENCIAS ESTÁN ABIERTAS FUERA DE ESTOS HORARIOS.", + "locations": [ + { + "name": "LANCASTER: Penny Lane Centers", + "address": "43520 Division St, Lancaster CA 93535", + "hoursEn": "SURVEYING: Tue-Fri 2:00 PM-6:00 PM, Sat 9:00 AM-7:00 PM", + "hoursEs": "ENCUESTAS: Mar-Vie 2:00 PM - 6:00 PM, Sab 9:00 AM - 7:00 PM" + }, + { + "name": "PALMDALE LANCASTER: Valley Oasis Thrift Shoppe", + "address": "3030 E Palmdale Blvd, Palmdale, CA 93550", + "hoursEn": "SURVEYING: Wed-Fri 11:00 AM-5:00 PM", + "hoursEs": "ENCUESTAS: Mie-Vie 11:00 AM - 5:00 PM" + } + ] + }, + "east": { + "headerEn": "EAST LA AND SAN GABRIEL VALLEY HUB SITES (SPA 7 and SPA 3)", + "headerEs": "CENTROS (HUBS) DE EAST LA Y SAN GABRIEL VALLEY (SPA 7 y SPA 3)", + "subheaderEn": "View all Hub Sites in LA County at youthcount.org/map", + "subheaderEs": "Vea los Centros (Hubs) en el Condado de Los Ángeles en youthcount.org/map", + "warningEn": "TIMES LISTED BELOW ARE DESIGNATED SURVEYING HOURS. AGENCIES OPEN OUTSIDE OF THESE HOURS.", + "warningEs": "LOS HORARIOS QUE SE INDICAN SON LAS HORAS DESIGNADAS PARA LAS ENCUESTAS. LAS AGENCIAS ESTÁN ABIERTAS FUERA DE ESTOS HORARIOS.", + "locations": [ + { + "name": "PASADENA: Youth Moving On", + "address": "456 E Orange Grove Blvd, Suite 140, Pasadena CA 91104", + "hoursEn": "SURVEYING: Tue-Fri 11:00 AM-4:00 PM", + "hoursEs": "ENCUESTAS: Mar-Vie 11:00 AM - 4:00 PM" + }, + { + "name": "IRWINDALE: Hope Drop-in Center", + "address": "13001 Ramona Blvd. Suite I, Irwindale, 91706", + "hoursEn": "SURVEYING: Tue-Sat 10:00 AM-4:00 PM", + "hoursEs": "ENCUESTAS: Mar-Sab 10:00 AM - 4:00 PM" + }, + { + "name": "WHITTIER: Jovenes Access Center", + "address": "9829 Carmenita Rd Suite H, Whittier CA 90605", + "hoursEn": "SURVEYING: Tue-Fri 10:00 AM-4:00 PM", + "hoursEs": "ENCUESTAS: Mar-Vie 10:00 AM - 4:00 PM" + }, + { + "name": "POMONA: God's Pantry", + "address": "480 W Monterey Ave, Pomona, CA 91768", + "hoursEn": "SURVEYING: Tue-Thu 10:00 AM-3:00 PM", + "hoursEs": "ENCUESTAS: Mar-Jue 10:00 AM - 3:00 PM" + }, + { + "name": "COMMERCE: Penny Lane Centers", + "address": "5628 E. Slauson Ave, Commerce, CA 90040", + "hoursEn": "SURVEYING: Tue-Fri 2:00 PM-6:00 PM, Sat 10:00 AM-7:00 PM", + "hoursEs": "ENCUESTAS: Mar-Vie 2:00 PM - 6:00 PM, Sab 10:00 AM - 7:00 PM" + } + ] + }, + "sw": { + "headerEn": "WEST LA AND SOUTH BAY HUB SITES (SPA 5 and 8)", + "headerEs": "CENTROS (HUBS) DE WEST LA Y SOUTH BAY (SPA 5 y 8)", + "subheaderEn": "View all Hub Sites in LA County at youthcount.org/map", + "subheaderEs": "Vea los Centros (Hubs) en el Condado de Los Ángeles en youthcount.org/map", + "warningEn": "TIMES LISTED BELOW ARE DESIGNATED SURVEYING HOURS. AGENCIES OPEN OUTSIDE OF THESE HOURS.", + "warningEs": "LOS HORARIOS QUE SE INDICAN SON LAS HORAS DESIGNADAS PARA LAS ENCUESTAS. LAS AGENCIAS ESTÁN ABIERTAS FUERA DE ESTOS HORARIOS.", + "locations": [ + { + "name": "VENICE: Safe Place For Youth", + "address": "2471 Lincoln Blvd Suite 101 Venice CA 90291", + "hoursEn": "SURVEYING: Tue-Fri 10:00 AM-6:00 PM, Sat 10:00 AM-3:00 PM", + "hoursEs": "ENCUESTAS: Mar-Vie 10:00 AM - 6:00 PM, Sab 10:00 AM - 3:00 PM" + }, + { + "name": "HAWTHORNE: Sanctuary Of Hope", + "address": "13245 Hawthorne Blvd, Hawthorne, CA 90250", + "hoursEn": "SURVEYING: Tue-Sat 10:00 AM-4:00 PM", + "hoursEs": "ENCUESTAS: Mar-Sab 10:00 AM - 4:00 PM" + }, + { + "name": "SAN PEDRO: Harbor Interfaith", + "address": "670 W 9th St, San Pedro, CA 90731", + "hoursEn": "SURVEYING: Tue-Fri 2:00 PM-5:00 PM", + "hoursEs": "ENCUESTAS: Mar-Vie 2:00 PM - 5:00 PM" + } + ] + } +} diff --git a/server/src/scripts/assets/kcrha_logo.png b/server/src/scripts/assets/kcrha_logo.png new file mode 100644 index 00000000..c57097a5 Binary files /dev/null and b/server/src/scripts/assets/kcrha_logo.png differ diff --git a/server/src/scripts/assets/logo.png b/server/src/scripts/assets/uw_logo.png similarity index 100% rename from server/src/scripts/assets/logo.png rename to server/src/scripts/assets/uw_logo.png diff --git a/server/src/scripts/generateSeeds.ts b/server/src/scripts/generateSeeds.ts index 8cdf5f88..8fa60022 100644 --- a/server/src/scripts/generateSeeds.ts +++ b/server/src/scripts/generateSeeds.ts @@ -1,400 +1,448 @@ #!/usr/bin/env tsx /** * Script to generate N seeds for a given location - * Usage: npm run generate-seeds -- - * Example: npm run generate-seeds -- "Main Hub" 10 - * Example: npm run generate-seeds -- 507f1f77bcf86cd799439011 10 + * Usage: npm run generate-seeds -- [templateKey] + * + * Examples: + * npm run generate-seeds -- "My Friends Place" 10 + * npm run generate-seeds -- "My Friends Place" 10 seattle + * npm run generate-seeds -- 692f9100056e7a6957d0f0a2 50 east + * + * Available templates: seattle, north, east, south, southeast, snoqualmie valley, vashon + * Default template: seattle */ + import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; -import mongoose from 'mongoose'; -import PDFDocument from 'pdfkit'; +import { dirname } from 'path'; import QRCode from 'qrcode'; - -import connectDB from '@/database'; -import Location from '@/database/location/mongoose/location.model'; -import Seed, { ISeed } from '@/database/seed/mongoose/seed.model'; -import { generateUniqueSurveyCode } from '@/database/survey/survey.controller'; +import PDFDocument from 'pdfkit'; +import mongoose from 'mongoose'; // Get current directory const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); +const __dirname = dirname(__filename); + +// ===== Location Template Configuration ===== -// Assets and output paths -const logoPath = path.join(__dirname, 'assets/logo.png'); -const seedsOutputDir = path.join(__dirname, 'seeds'); +interface LocationInfo { + name: string; + address: string; + daysEn?: string; + daysEs?: string; + hoursEn?: string; + hoursEs?: string; +} + +interface LocationTemplate { + headerEn: string; + headerEs: string; + subheaderEn: string; + subheaderEs: string; + subsubheaderEn: string; + subsubheaderEs: string; + warningEn: string; + warningEs: string; + locations: LocationInfo[]; +} + +// Load templates from external JSON file +function loadTemplates(): Record { + const templatesPath = path.join(__dirname, 'seed_templates.json'); + try { + const templatesContent = fs.readFileSync(templatesPath, 'utf-8'); + return JSON.parse(templatesContent); + } catch (error) { + throw new Error(`Failed to load seed templates from ${templatesPath}: ${error instanceof Error ? error.message : error}`); + } +} // ===== PDF Generation Helper Functions ===== function createOutputDirectory(): string { - if (!fs.existsSync(seedsOutputDir)) { - fs.mkdirSync(seedsOutputDir, { recursive: true }); - } - return seedsOutputDir; + const outputDir = path.join(__dirname, 'seeds'); + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + return outputDir; +} + +function generateTimestampFilename(templateKey: string, count: number, outputDir: string): string { + const now = new Date(); + const timestamp = [ + now.getFullYear(), + String(now.getMonth() + 1).padStart(2, '0'), + String(now.getDate()).padStart(2, '0'), + String(now.getHours()).padStart(2, '0'), + String(now.getMinutes()).padStart(2, '0'), + String(now.getSeconds()).padStart(2, '0') + ].join(''); + const sanitizedTemplateKey = templateKey.replace(/[^a-z0-9]/gi, '-').toLowerCase(); + const filename = `kcrha-pit-2026-${sanitizedTemplateKey}-${count}-seeds-${timestamp}.pdf`; + return path.join(outputDir, filename); } -function generateTimestampFilename( - locationName: string, - outputDir: string -): string { - const now = new Date(); - const timestamp = [ - now.getFullYear(), - String(now.getMonth() + 1).padStart(2, '0'), - String(now.getDate()).padStart(2, '0'), - String(now.getHours()).padStart(2, '0'), - String(now.getMinutes()).padStart(2, '0'), - String(now.getSeconds()).padStart(2, '0') - ].join(''); - const sanitizedLocationName = locationName - .replace(/[^a-z0-9]/gi, '-') - .toLowerCase(); - const filename = `seeds-${sanitizedLocationName}-${timestamp}.pdf`; - return path.join(outputDir, filename); +async function generateQRCodeBuffer(surveyCode: string, qrSize: number): Promise { + // Encode only the referral code (no URL) so QR codes work across any deployment + const qrDataUrl = await QRCode.toDataURL(surveyCode, { + width: qrSize, + margin: 1, + errorCorrectionLevel: 'M', + }); + return Buffer.from(qrDataUrl.split(',')[1], 'base64'); } -async function generateQRCodeBuffer( - surveyCode: string, - qrSize: number -): Promise { - // Encode only the coupon code (no URL) so QR codes work across any deployment - const qrDataUrl = await QRCode.toDataURL(surveyCode, { - width: qrSize, - margin: 1, - errorCorrectionLevel: 'M' - }); - return Buffer.from(qrDataUrl.split(',')[1], 'base64'); +// ===== Simplified English Page ===== + +async function addEnglishPage(doc: any, surveyCode: string, template: LocationTemplate): Promise { + const pageWidth = doc.page.width; + const margin = 36; + const contentWidth = pageWidth - margin * 2; + + // --- Top: KCRHA Logo --- + let currentY = margin; + const logoPath = path.join(__dirname, 'assets', 'kcrha_logo.png'); + const logoWidth = 150; + doc.image(logoPath, margin, currentY, { width: logoWidth }); + currentY += 45; + + // --- Title --- + doc.fontSize(14) + .font('Helvetica-Bold') + .text('Coupon — Unsheltered Point-in-Time Count', margin, currentY, { width: contentWidth }); + currentY += 20; + + // --- QR code box (top right corner) --- + const qrSize = 100; + const qrX = pageWidth - margin - qrSize; + const qrStartY = currentY; + const qrBuffer = await generateQRCodeBuffer(surveyCode, qrSize); + doc.rect(qrX - 2, qrStartY - 2, qrSize + 4, qrSize + 4).stroke('#000000'); + doc.image(qrBuffer, qrX, qrStartY, { width: qrSize, height: qrSize }); + doc.fontSize(10).font('Helvetica-Bold').text(surveyCode, qrX, qrStartY + qrSize + 4, { width: qrSize, align: 'center' }); + const qrEndY = qrStartY + qrSize + 14; // QR code + text below + + // --- Subheader text (beside QR code) --- + const textWidth = contentWidth - qrSize - 20; + doc.fontSize(10) + .font('Helvetica') + .text(template.subheaderEn, margin, currentY, { width: textWidth }); + + const subheaderHeight = doc.heightOfString(template.subheaderEn, { width: textWidth }); + currentY += subheaderHeight + 6; + + // --- Subsubheader text (next line, same width) --- + doc.fontSize(10) + .font('Helvetica') + .text(template.subsubheaderEn, margin, currentY, { width: textWidth }); + + const subsubheaderHeight = doc.heightOfString(template.subsubheaderEn, { width: textWidth }); + currentY += subsubheaderHeight; + + // Move currentY to whichever ends first (text or QR) + currentY = Math.min(currentY + 20, qrEndY); + + // --- Locations header --- + doc.fontSize(12) + .font('Helvetica-Bold') + .text(template.headerEn, margin, currentY, { width: contentWidth }); + currentY += 16; + + // --- Locations list --- + for (const location of template.locations) { + // Location name (bold) + doc.fontSize(10).font('Helvetica-Bold').fillColor('#000000'); + doc.text(location.name, margin, currentY, { width: contentWidth }); + currentY += doc.heightOfString(location.name, { width: contentWidth }) + 3; + + // Address + doc.fontSize(10).font('Helvetica'); + doc.text(location.address, margin, currentY, { width: contentWidth }); + currentY += doc.heightOfString(location.address, { width: contentWidth }) + 3; + + // Days and Hours (combined if both exist) + const parts = []; + if (location.daysEn) parts.push(location.daysEn); + if (location.hoursEn) parts.push(location.hoursEn); + if (parts.length > 0) { + const timeInfo = parts.join(', '); + doc.text(timeInfo, margin, currentY, { width: contentWidth }); + currentY += doc.heightOfString(timeInfo, { width: contentWidth }); + } + + currentY += 10; // Space between locations + } + + // --- Footer (positioned at bottom of page) --- + const pageHeight = doc.page.height; + const footerY = pageHeight - margin - 40; // Reserve space at bottom + doc.fontSize(9) + .font('Helvetica') + .fillColor('#000000') + .text(template.warningEn, margin, footerY, { width: contentWidth, align: 'left', lineGap: 2 }); } -async function addQRCodePage( - doc: PDFKit.PDFDocument, - surveyCode: string, - _locationName: string, - isFirstPage: boolean -): Promise { - if (!isFirstPage) { - doc.addPage(); - } - - const pageWidth = doc.page.width; - const margin = 50; - const contentWidth = pageWidth - margin * 2; - - let currentY = margin; - - if (fs.existsSync(logoPath)) { - const logoWidth = 60; - doc.image(logoPath, (pageWidth - logoWidth) / 2, currentY, { - fit: [logoWidth, logoWidth] - }); - currentY += logoWidth + 10; - } - - // Title - doc.fontSize(18) - .font('Helvetica-Bold') - .text('Understanding Unsheltered Homelessness', margin, currentY, { - align: 'center', - width: contentWidth - }); - - currentY += 40; - - // Instructions - doc.fontSize(12) - .font('Helvetica') - .text( - 'Bring this coupon to one of the locations below to complete a survey about your experience being unsheltered (including living in an RV or car/vehicle) and to receive a ', - margin, - currentY, - { - align: 'left', - width: contentWidth, - continued: true - } - ) - .font('Helvetica-Bold') - .text('$20', { continued: true }) - .font('Helvetica') - .text(' Gift Card.'); - - currentY += 50; - - doc.fontSize(12) - .font('Helvetica') - .text( - 'Our locations are accessible with free parking and bike racks unless marked otherwise.', - margin, - currentY, - { - align: 'left', - width: contentWidth - } - ); - - currentY += 25; - - doc.text('Pets and service animals welcome.', margin, currentY, { - align: 'left', - width: contentWidth - }); - - currentY += 50; - - // QR Code and Coupon Code - const qrSize = 100; - const qrX = (pageWidth - qrSize) / 2; - - const qrBuffer = await generateQRCodeBuffer(surveyCode, qrSize); - doc.image(qrBuffer, qrX, currentY, { - width: qrSize, - height: qrSize - }); - - currentY += qrSize + 15; - - doc.fontSize(16) - .font('Helvetica-Bold') - .text(`Coupon Code: ${surveyCode}`, margin, currentY, { - align: 'center', - width: contentWidth - }); - - currentY += 50; - - // Locations section - doc.fontSize(12) - .font('Helvetica-Bold') - .text('Locations', margin, currentY, { - align: 'left', - width: contentWidth - }); - - currentY += 20; - - doc.fontSize(11) - .font('Helvetica') - .text('• Highline United Methodist Church', margin + 10, currentY, { - align: 'left', - width: contentWidth - 10 - }); - - currentY += 15; - - doc.text(' 13015 1st AVE S, Burien, WA 98168', margin + 10, currentY, { - align: 'left', - width: contentWidth - 10 - }); - - currentY += 20; - - doc.text('• Interview Dates and Hours:', margin + 10, currentY, { - align: 'left', - width: contentWidth - 10 - }); - - currentY += 15; - - doc.text(' Monday - Friday (11/17 - 11/21)', margin + 10, currentY, { - align: 'left', - width: contentWidth - 10 - }); - - currentY += 15; - - doc.text(' 10am to 3pm', margin + 10, currentY, { - align: 'left', - width: contentWidth - 10 - }); - - currentY += 50; - - // Contact info - doc.fontSize(10) - .font('Helvetica') - .text( - 'For questions, please call +1 (833) 393-1621', - margin, - currentY, - { - align: 'center', - width: contentWidth - } - ); +// ===== Simplified Spanish Page ===== + +async function addSpanishPage(doc: any, surveyCode: string, template: LocationTemplate): Promise { + const pageWidth = doc.page.width; + const margin = 36; + const contentWidth = pageWidth - margin * 2; + + // --- Top: KCRHA Logo --- + let currentY = margin; + const logoPath = path.join(__dirname, 'assets', 'kcrha_logo.png'); + const logoWidth = 150; + doc.image(logoPath, margin, currentY, { width: logoWidth }); + currentY += 45; + + // --- Title --- + doc.fontSize(14) + .font('Helvetica-Bold') + .text('Cupón — Un recuento de personas sin hogar', margin, currentY, { width: contentWidth }); + currentY += 20; + + // --- QR code box (top right corner) --- + const qrSize = 100; + const qrX = pageWidth - margin - qrSize; + const qrStartY = currentY; + const qrBuffer = await generateQRCodeBuffer(surveyCode, qrSize); + doc.rect(qrX - 2, qrStartY - 2, qrSize + 4, qrSize + 4).stroke('#000000'); + doc.image(qrBuffer, qrX, qrStartY, { width: qrSize, height: qrSize }); + doc.fontSize(10).font('Helvetica-Bold').text(surveyCode, qrX, qrStartY + qrSize + 4, { width: qrSize, align: 'center' }); + const qrEndY = qrStartY + qrSize + 14; // QR code + text below + + // --- Subheader text (beside QR code) --- + const textWidth = contentWidth - qrSize - 20; + doc.fontSize(10) + .font('Helvetica') + .text(template.subheaderEs, margin, currentY, { width: textWidth }); + + const subheaderHeight = doc.heightOfString(template.subheaderEs, { width: textWidth }); + currentY += subheaderHeight + 6; + + // --- Subsubheader text (next line, same width) --- + doc.fontSize(10) + .font('Helvetica') + .text(template.subsubheaderEs, margin, currentY, { width: textWidth }); + + const subsubheaderHeight = doc.heightOfString(template.subsubheaderEs, { width: textWidth }); + currentY += subsubheaderHeight; + + // Move currentY past both sections (text or QR, whichever is lower) + currentY = Math.min(currentY + 20, qrEndY); + + // --- Locations header --- + doc.fontSize(12) + .font('Helvetica-Bold') + .text(template.headerEs, margin, currentY, { width: contentWidth }); + currentY += 16; + + // --- Locations list --- + for (const location of template.locations) { + // Location name (bold) + doc.fontSize(10).font('Helvetica-Bold').fillColor('#000000'); + doc.text(location.name, margin, currentY, { width: contentWidth }); + currentY += doc.heightOfString(location.name, { width: contentWidth }) + 3; + + // Address + doc.fontSize(10).font('Helvetica'); + doc.text(location.address, margin, currentY, { width: contentWidth }); + currentY += doc.heightOfString(location.address, { width: contentWidth }) + 3; + + // Days and Hours (combined if both exist) + const parts = []; + if (location.daysEs) parts.push(location.daysEs); + if (location.hoursEs) parts.push(location.hoursEs); + if (parts.length > 0) { + const timeInfo = parts.join(', '); + doc.text(timeInfo, margin, currentY, { width: contentWidth }); + currentY += doc.heightOfString(timeInfo, { width: contentWidth }); + } + + currentY += 10; // Space between locations + } + + // --- Footer (positioned at bottom of page) --- + const pageHeight = doc.page.height; + const footerY = pageHeight - margin - 40; // Reserve space at bottom + doc.fontSize(9) + .font('Helvetica') + .fillColor('#000000') + .text(template.warningEs, margin, footerY, { width: contentWidth, align: 'left', lineGap: 2 }); } -async function generatePDF( - seeds: ISeed[], - locationName: string -): Promise { - const outputDir = createOutputDirectory(); - const filepath = generateTimestampFilename(locationName, outputDir); - - // Create PDF document - const doc = new PDFDocument({ - size: 'LETTER', - margin: 50 - }); - - const stream = fs.createWriteStream(filepath); - doc.pipe(stream); - - // Generate one page per seed - for (let i = 0; i < seeds.length; i++) { - await addQRCodePage(doc, seeds[i].surveyCode, locationName, i === 0); - } - - doc.end(); - - // Wait for stream to finish - await new Promise((resolve, reject) => { - stream.on('finish', () => resolve()); - stream.on('error', reject); - }); - - console.log(`\n✓ PDF generated: ${filepath}`); - console.log(` Contains ${seeds.length} QR code(s), one per page`); +async function generatePDF(seeds: any[], templateKey: string = 'seattle'): Promise { + const outputDir = createOutputDirectory(); + const filepath = generateTimestampFilename(templateKey, seeds.length, outputDir); + + // Load and get the location template + const templates = loadTemplates(); + const template = templates[templateKey]; + if (!template) { + throw new Error(`Template "${templateKey}" not found. Available templates: ${Object.keys(templates).join(', ')}`); + } + + const doc = new PDFDocument({ + size: 'LETTER', + margin: 30, + autoFirstPage: false + }); + + const stream = fs.createWriteStream(filepath); + doc.pipe(stream); + + // Generate two-sided coupons (English + Spanish) for each seed + for (const seed of seeds) { + // Add English page + doc.addPage(); + await addEnglishPage(doc, seed.surveyCode, template); + + // Add Spanish page + doc.addPage(); + await addSpanishPage(doc, seed.surveyCode, template); + } + + doc.end(); + + await new Promise((resolve, reject) => { + stream.on('finish', resolve); + stream.on('error', reject); + }); + + console.log(`\n✓ PDF generated: ${filepath}`); + console.log(` Total pages: ${seeds.length * 2} (${seeds.length} English + ${seeds.length} Spanish)`); + console.log(` Using template: ${templateKey}`); } // ===== Seed Generation Helper Functions ===== function isValidObjectId(identifier: string): boolean { - return ( - mongoose.Types.ObjectId.isValid(identifier) && - /^[0-9a-fA-F]{24}$/.test(identifier) - ); + return mongoose.Types.ObjectId.isValid(identifier) && /^[0-9a-fA-F]{24}$/.test(identifier); } -async function findLocationByIdentifier(locationIdentifier: string) { - const isObjectId = isValidObjectId(locationIdentifier); - - let location; - if (isObjectId) { - console.log( - `Looking up location with ObjectId: "${locationIdentifier}"...` - ); - location = await Location.findById(locationIdentifier); - } else { - console.log( - `Looking up location with hubName: "${locationIdentifier}"...` - ); - location = await Location.findOne({ hubName: locationIdentifier }); - } - - if (!location) { - const idType = isObjectId ? 'ObjectId' : 'hubName'; - throw new Error( - `Location with ${idType} "${locationIdentifier}" not found` - ); - } - - console.log(`Found location: ${location.hubName} (${location._id}) ✓\n`); - return location; +async function findLocationByIdentifier(locationIdentifier: string, Location: any): Promise { + const isObjectId = isValidObjectId(locationIdentifier); + + let location; + if (isObjectId) { + console.log(`Looking up location with ObjectId: "${locationIdentifier}"...`); + location = await Location.findById(locationIdentifier); + } else { + console.log(`Looking up location with hubName: "${locationIdentifier}"...`); + location = await Location.findOne({ hubName: locationIdentifier }); + } + + if (!location) { + const idType = isObjectId ? 'ObjectId' : 'hubName'; + throw new Error(`Location with ${idType} "${locationIdentifier}" not found`); + } + + console.log(`Found location: ${location.hubName} (${location._id}) ✓\n`); + return location; } -async function createSeed( - surveyCode: string, - locationId: mongoose.Types.ObjectId, - index: number, - total: number -): Promise { - try { - const seed = await Seed.create({ - surveyCode, - locationObjectId: locationId, - isFallback: false - }); - - console.log( - ` [${index + 1}/${total}] Created seed: ${seed.surveyCode} (${seed._id})` - ); - return seed; - } catch (error) { - console.error( - ` [${index + 1}/${total}] Failed to create seed:`, - error - ); - throw error; - } +async function createSeed(surveyCode: string, locationId: any, Seed: any, templateKey: string, index: number, total: number): Promise { + try { + const seed = await Seed.create({ + surveyCode, + locationObjectId: locationId, + isFallback: false, + }); + + console.log(` [${index + 1}/${total}] Created seed: ${seed.surveyCode} (${seed._id})`); + return seed; + } catch (error) { + console.error(` [${index + 1}/${total}] Failed to create seed:`, error); + throw error; + } } async function generateSeedsForLocation( - location: { _id: mongoose.Types.ObjectId; hubName: string }, - count: number -): Promise { - console.log(`Generating ${count} seed(s)...\n`); - const createdSeeds: ISeed[] = []; - - for (let i = 0; i < count; i++) { - const surveyCode = await generateUniqueSurveyCode(); - const seed = await createSeed(surveyCode, location._id, i, count); - createdSeeds.push(seed); - } - - return createdSeeds; + location: any, + count: number, + Seed: any, + generateUniqueSurveyCode: () => Promise, + templateKey: string, +): Promise { + console.log(`Generating ${count} seed(s)...\n`); + const createdSeeds: any[] = []; + + for (let i = 0; i < count; i++) { + const surveyCode = await generateUniqueSurveyCode(); + const seed = await createSeed(surveyCode, location._id, Seed, templateKey, i, count); + createdSeeds.push(seed); + } + + return createdSeeds; } -function printSeedsSummary(seeds: ISeed[], locationName: string): void { - console.log( - `\n✓ Successfully generated ${seeds.length} seed(s) for location "${locationName}"` - ); - console.log('\nGenerated Survey Codes:'); - seeds.forEach((seed, index) => { - console.log(` ${index + 1}. ${seed.surveyCode}`); - }); +function printSeedsSummary(seeds: any[], locationName: string): void { + console.log(`\n✓ Successfully generated ${seeds.length} seed(s) for location "${locationName}"`); + console.log('\nGenerated Survey Codes:'); + seeds.forEach((seed, index) => { + console.log(` ${index + 1}. ${seed.surveyCode}`); + }); } -async function generateSeeds( - locationIdentifier: string, - count: number -): Promise { - try { - console.log('Connecting to database...'); - await connectDB(); - console.log('Connected to database ✓\n'); - - const location = await findLocationByIdentifier(locationIdentifier); - const createdSeeds = await generateSeedsForLocation(location, count); - - printSeedsSummary(createdSeeds, location.hubName); - - console.log('\n📄 Generating PDF with QR codes...'); - await generatePDF(createdSeeds, location.hubName); - } catch (error) { - console.error( - '\n✗ Error:', - error instanceof Error ? error.message : error - ); - process.exit(1); - } finally { - await mongoose.connection.close(); - console.log('\nDatabase connection closed.'); - process.exit(0); - } +async function generateSeeds(locationIdentifier: string, count: number, templateKey?: string): Promise { + const Location = (await import('@/database/location/mongoose/location.model')).default; + const Seed = (await import('@/database/seed/mongoose/seed.model')).default; + const { generateUniqueSurveyCode } = await import('@/database/survey/survey.controller'); + const connectDB = (await import('@/database/index')).default; + + try { + console.log('Connecting to database...'); + await connectDB(); + console.log('Connected to database ✓\n'); + + const location = await findLocationByIdentifier(locationIdentifier, Location); + const createdSeeds = await generateSeedsForLocation(location, count, Seed, generateUniqueSurveyCode, templateKey || 'seattle'); + + printSeedsSummary(createdSeeds, location.hubName); + + console.log('\n📄 Generating PDF with QR codes...'); + await generatePDF(createdSeeds, templateKey); + } catch (error) { + console.error('\n✗ Error:', error instanceof Error ? error.message : error); + process.exit(1); + } finally { + await mongoose.connection.close(); + console.log('\nDatabase connection closed.'); + process.exit(0); + } } // Parse command line arguments const args = process.argv.slice(2); -if (args.length !== 2) { - console.error( - 'Usage: npm run generate-seeds -- ' - ); - console.error('Example: npm run generate-seeds -- "Main Hub" 10'); - console.error( - 'Example: npm run generate-seeds -- 507f1f77bcf86cd799439011 10' - ); - process.exit(1); +if (args.length < 2 || args.length > 3) { + const templates = loadTemplates(); + console.error('Usage: npm run generate-seeds -- [templateKey]'); + console.error(''); + console.error('Examples:'); + console.error(' npm run generate-seeds -- "My Friends Place" 10'); + console.error(' npm run generate-seeds -- 507f1f77bcf86cd799439011 100 metro'); + console.error(' npm run generate-seeds -- 692fc19f0d01f4b400e665d0 50 east'); + console.error(''); + console.error('Available templates:'); + Object.keys(templates).forEach(key => { + const template = templates[key]; + console.error(` - ${key}: ${template.headerEn}`); + }); + console.error(''); + console.error('Default template: seattle'); + process.exit(1); } -const [locationIdentifier, countStr] = args; +const [locationIdentifier, countStr, templateKey] = args; const count = parseInt(countStr, 10); if (isNaN(count) || count <= 0) { - console.error('Error: count must be a positive number'); - process.exit(1); + console.error('Error: count must be a positive number'); + process.exit(1); } // Run the script -generateSeeds(locationIdentifier, count); +generateSeeds(locationIdentifier, count, templateKey); diff --git a/server/src/scripts/seed_templates.json b/server/src/scripts/seed_templates.json new file mode 100644 index 00000000..9c621d08 --- /dev/null +++ b/server/src/scripts/seed_templates.json @@ -0,0 +1,304 @@ +{ + "seattle": { + "headerEn": "Seattle Survey Locations", + "headerEs": "Ubicaciones en Seattle", + "subheaderEn": "Bring this coupon to one of the locations below during a date and time listed. Complete a survey about your experience being unsheltered (including living in an RV or car/vehicle) and receive a $20 Gift Card.", + "subheaderEs": "Lleve este cupón a una de las siguientes ubicaciones para completar una encuesta sobre su experiencia sin refugio (incluido vivir en un coche) y recibir una tarjeta de regalo de $20.", + "subsubheaderEn": "Language translation (including ASL) will be available. Pets and service animals welcome.", + "subsubheaderEs": "Todas las ubicaciones son accesibles con estacionamiento gratuito y portabicicletas. Habrá traducción de idiomas (incluido ASL) disponible.", + "warningEn": "Survey data results will be used by the King County Continuum of Care (CoC) to estimate the number of people living unsheltered in King County. Find out more at kcrha.org/pit.", + "warningEs": "Los resultados de la encuesta serán utilizados por el King County Continuum de Care (CoC) para estimar el número de personas viviendo sin refugio en King County. Descubre más en kcrha.org/pit", + "locations": [ + { + "name": "Opportunity Center at North Seattle College", + "address": "9600 College Way N, Seattle, WA 98103", + "daysEn": "January 26-30 (Monday-Friday)", + "daysEs": "26-30 de enero (lunes-viernes)", + "hoursEn": "10:30am to 2:30pm", + "hoursEs": "10:30am - 2:30pm" + }, + { + "name": "YouthCare Orion Center (Use Entrance on Stewart St.)", + "address": "1828 Yale Ave, Seattle, WA 98101", + "daysEn": "January 26-29 and February 2-5", + "daysEs": "26-29 de enero y 2-5 de febrero (lunes-jueves)", + "hoursEn": "Mon/Tue 10am to 2pm, Wed/Thu 2pm to 6pm", + "hoursEs": "lun/mar 10am - 2pm, miérc/juev 2pm - 6pm" + }, + { + "name": "Lake City Library", + "address": "12501 28th Ave NE, Seattle, WA 98125", + "daysEn": "February 2-6 (Monday-Friday)", + "daysEs": "2-6 de febrero (lunes-viernes)", + "hoursEn": "10:30am to 2:30pm", + "hoursEs": "10:30am - 2:30pm" + }, + { + "name": "Compass Day Center", + "address": "210 Alaskan Way , Seattle, WA 98104", + "daysEn": "February 2-6 (Monday-Friday)", + "daysEs": "2-6 de febrero (lunes-viernes)", + "hoursEn": "10:30am to 2:30pm", + "hoursEs": "10:30am - 2:30pm" + }, + { + "name": "SVdP Georgetown Foodbank", + "address": "5972 4th Ave S, Seattle, WA 98108", + "daysEn": "January 27, 29-30 , February 3, 5-6 (Tue/Thu/Fri)", + "daysEs": "27, 29-30 de enero y 3, 5-6 de febrero (mar/juev/vier)", + "hoursEn": "11am to 2pm", + "hoursEs": "11am - 2pm" + }, + { + "name": "St. James Cathedral", + "address": "804 9th Ave, Seattle, WA 98104", + "daysEn": "January 26-29 (Monday-Thursday)", + "daysEs": "26-29 de enero (lunes-jueves)", + "hoursEn": "1pm to 4pm", + "hoursEs": "1pm - 4pm" + }, + { + "name": "South Lucile Street VA Center", + "address": "305 S Lucile St, STE 103, Seattle, WA 98104", + "daysEn": "February 2-6 (Monday-Friday)", + "daysEs": "2-6 de febrero (lunes-viernes)", + "hoursEn": "9am to 12pm", + "hoursEs": "9am - 12pm" + }, + { + "name": "Allen Family Center (Families with Children Only)", + "address": "3190 Martin Luther King Jr Way S, Seattle, WA 98144", + "daysEn": "January 26 - February 6 (Weekdays only)", + "daysEs": "26 de enero - 6 de febrero (lunes-viernes)", + "hoursEn": "11:30am to 1:30pm", + "hoursEs": "11:30am - 1:30pm" + }, + { + "name": "Southwest Library", + "address": "9010 35th Ave SW, Seattle, WA 98126", + "daysEn": "January 26-29 (Monday-Thursday)", + "daysEs": "26-29 de enero (lunes-jueves)", + "hoursEn": "Mon-Wed 1:30pm to 5:30pm, Thu 10:30am to 2:30pm", + "hoursEs": "lun-miérc 1:30pm - 5:30pm, jueves 10:30am - 2:30pm" + }, + { + "name": "South Park Library", + "address": "8604 8th Ave S, Seattle, WA 98108", + "daysEn": "February 2-6 (Monday-Friday)", + "daysEs": "2-6 de febrero (lunes-viernes)", + "hoursEn": "Mon/Thu/Fri 10:30am to 2:30pm, Tue/Wed 1:30pm to 4:30pm", + "hoursEs": "lun/juev/vier 10:30am - 2:30pm, mar/miérc 1:30pm - 4:30pm" + } + ] + }, + "north": { + "headerEn": "North King County Survey Locations", + "headerEs": "Ubicaciones en Condado de King del norte", + "subheaderEn": "Bring this coupon to one of the locations below during a date and time listed. Complete a survey about your experience being unsheltered (including living in an RV or car/vehicle) and receive a $20 Gift Card.", + "subheaderEs": "Lleve este cupón a una de las siguientes ubicaciones para completar una encuesta sobre su experiencia sin refugio (incluido vivir en un coche) y recibir una tarjeta de regalo de $20.", + "subsubheaderEn": "Language translation (including ASL) will be available. Pets and service animals welcome.", + "subsubheaderEs": "Todas las ubicaciones son accesibles con estacionamiento gratuito y portabicicletas. Habrá traducción de idiomas (incluido ASL) disponible. Mascotas y animales de servicio son bienvenidos.", + "warningEn": "Survey data results will be used by the King County Continuum of Care (CoC) to estimate the number of people living unsheltered in King County. Find out more at kcrha.org/pit.", + "warningEs": "Los resultados de la encuesta serán utilizados por el King County Continuum of Care (CoC) para estimar el número de personas viviendo sin refugio en King County. Descubre más en kcrha.org/pit", + "locations": [ + { + "name": "Shoreline Library", + "address": "345 NE 175th St, Shoreline, WA 98155", + "daysEn": "January 26, 28, 29 and February 2, 4", + "daysEs": "26, 28, 29 de enero y 2, 4 de febrero", + "hoursEn": "Mon/Fri 10:30am to 2:30pm, Wed 12:30pm to 4:30pm", + "hoursEs": "lun/vier 10:30am - 2:30pm, miércoles 12:30pm - 4:30pm" + }, + { + "name": "Ronald United Methodist Church", + "address": "17839 Aurora Ave N, Shoreline, WA 98133", + "daysEn": "February 2-6 (Monday-Friday)", + "daysEs": "2-6 de febrero (lunes-viernes)", + "hoursEn": "3pm to 7pm", + "hoursEs": "3pm - 7pm" + }, + { + "name": "Lake City Library", + "address": "12501 28th Ave NE, Seattle, WA 98125", + "daysEn": "February 2-6 (Monday-Friday)", + "daysEs": "2-6 de febrero (lunes-viernes)", + "hoursEn": "10:30am to 2:30 pm", + "hoursEs": "10:30am - 2:30 pm" + } + ] + }, + "east": { + "headerEn": "East King County Survey Locations", + "headerEs": "Ubicaciones en Condado de King del este", + "subheaderEn": "Bring this coupon to one of the locations below during a date and time listed. Complete a survey about your experience being unsheltered (including living in an RV or car/vehicle) and receive a $20 Gift Card.", + "subheaderEs": "Lleve este cupón a una de las siguientes ubicaciones para completar una encuesta sobre su experiencia sin refugio (incluido vivir en un coche) y recibir una tarjeta de regalo de $20.", + "subsubheaderEn": "Language translation (including ASL) will be available. Pets and service animals welcome.", + "subsubheaderEs": "Todas las ubicaciones son accesibles con estacionamiento gratuito y portabicicletas. Habrá traducción de idiomas (incluido ASL) disponible. Mascotas y animales de servicio son bienvenidos.", + "warningEn": "Survey data results will be used by the King County Continuum of Care (CoC) to estimate the number of people living unsheltered in King County. Find out more at kcrha.org/pit.", + "warningEs": "Los resultados de la encuesta serán utilizados por el King County Continuum of Care (CoC) para estimar el número de personas viviendo sin refugio en King County. Descubre más en kcrha.org/pit", + "locations": [ + { + "name": "Overlake Christian Church", + "address": "9900 Willows Rd. NE, Redmond, WA 98052", + "daysEn":"January 27-29 and February 2-3 (Tue-Thu, Mon-Tue)", + "daysEs": "27-29 de enero, 2-3 de febrero (mar-juev, lun-mar)", + "hoursEn": "9am to 1pm", + "hoursEs": "9am - 1pm" + }, + { + "name": "Kirkland Library", + "address": "308 Kirkland Ave, Kirkland, WA 98033", + "daysEn": "February 2-6 (Monday-Friday)", + "daysEs": "2-6 de febrero (lunes-viernes)", + "hoursEn": "Mon/Thu/Fri 1:30pm to 5:30pm, Tue/Wed 2pm to 6pm", + "hoursEs": "un/juev/vier 1:30pm - 5:30pm, mar/miérc 2pm - 6pm" + }, + { + "name": "Issaquah Community Hall", + "address": "180 E Sunset Way, Issaquah, WA 98027", + "daysEn": "January 26-30 (Monday-Friday)", + "daysEs": "26-30 de enero (lunes-viernes)", + "hoursEn": "3pm to 6pm", + "hoursEs": "3pm - 6pm" + }, + { + "name": "Bellevue Library", + "address": "111 110th Ave NE, Bellevue, WA 98004", + "daysEn": "January 26-30 (Monday-Friday)", + "daysEs": "26-30 de enero (lunes-viernes)", + "hoursEn": "Mon/Thu/Fri 11am to 2pm, Tue/Wed 1pm-5pm", + "hoursEs": "lun/juev/vier 11am - 2pm, mar/miérc 1pm - 5pm" + } + ] + }, + "snoqualmie valley": { + "headerEn": "Snoqualmie Valley Survey Locations", + "headerEs": "Ubicaciones en Snoqualmie Valley", + "subheaderEn": "Bring this coupon to one of the locations below during a date and time listed. Complete a survey about your experience being unsheltered (including living in an RV or car/vehicle) and receive a $20 Gift Card.", + "subheaderEs": "Lleve este cupón a una de las siguientes ubicaciones para completar una encuesta sobre su experiencia sin refugio (incluido vivir en un coche) y recibir una tarjeta de regalo de $20.", + "subsubheaderEn": "Language translation (including ASL) will be available. Pets and service animals welcome.", + "subsubheaderEs": "Todas las ubicaciones son accesibles con estacionamiento gratuito y portabicicletas. Habrá traducción de idiomas (incluido ASL) disponible. Mascotas y animales de servicio son bienvenidos.", + "warningEn": "Survey data results will be used by the King County Continuum of Care (CoC) to estimate the number of people living unsheltered in King County. Find out more at kcrha.org/pit.", + "warningEs": "Los resultados de la encuesta serán utilizados por el King County Continuum of Care (CoC) para estimar el número de personas viviendo sin refugio en King County. Descubre más en kcrha.org/pit", + "locations": [ + { + "name": "Reclaim", + "address": "8224 Railroad Ave, Snoqualmie, WA 98065", + "daysEn": "January 26 - February 5 (Weekdays Only)", + "daysEs": "26 de enero - 5 de febrero (Solo entre semana)", + "hoursEn": "10am to 1pm", + "hoursEs": "10am - 1pm" + }, + { + "name": "North Bend Library", + "address": "115 E 4th St, North Bend, WA 98045", + "daysEn": "February 2-6, (Monday-Friday)", + "daysEs": "2-6 de febrero (lunes-viernes)", + "hoursEn": "3pm to 5:30pm", + "hoursEs": "3pm - 5:30pm" + } + ] + }, + "southeast": { + "headerEn": "Southeast King County Locations", + "headerEs": "Ubicaciones en Condado de King del sudeste", + "subheaderEn": "Bring this coupon to one of the locations below during a date and time listed. Complete a survey about your experience being unsheltered (including living in an RV or car/vehicle) and receive a $20 Gift Card.", + "subheaderEs": "Lleve este cupón a una de las siguientes ubicaciones para completar una encuesta sobre su experiencia sin refugio (incluido vivir en un coche) y recibir una tarjeta de regalo de $20.", + "subsubheaderEn": "Language translation (including ASL) will be available. Pets and service animals welcome.", + "subsubheaderEs": "Todas las ubicaciones son accesibles con estacionamiento gratuito y portabicicletas. Habrá traducción de idiomas (incluido ASL) disponible. Mascotas y animales de servicio son bienvenidos.", + "warningEn": "Survey data results will be used by the King County Continuum of Care (CoC) to estimate the number of people living unsheltered in King County. Find out more at kcrha.org/pit.", + "warningEs": "Los resultados de la encuesta serán utilizados por el King County Continuum of Care (CoC) para estimar el número de personas viviendo sin refugio en King County. Descubre más en kcrha.org/pit", + "locations": [ + { + "name": "Plateau Outreach Ministries", + "address": "1806 Cole St, Enumclaw, WA 98022", + "daysEn": "January 26-28 and February 2-4 (Monday-Wednesday)", + "daysEs": "26-28 de enero y 2-4 de febrero (lunes-miércoles)", + "hoursEn": "Mon 12 to 3pm, Tue/Wed 12 to 4pm", + "hoursEs": "Lunes 12pm - 3pm, mar/miérc 12pm - 4pm" + }, + { + "name": "Maple Valley Food Bank", + "address": "21415 Renton-Maple Valley Rd, Maple Valley, WA 98038", + "daysEn": "January 26-28 and February 2-4 (Monday-Wednesday)", + "daysEs": "26-28 de enero y 2-4 de febrero (lunes-miércoles)", + "hoursEn": "9am to 1pm", + "hoursEs": "9am - 1pm" + } + ] + }, + "south": { + "headerEn": "South King County Survey Locations", + "headerEs": "Ubicaciones en Condado de King del sur", + "subheaderEn": "Bring this coupon to one of the locations below during a date and time listed. Complete a survey about your experience being unsheltered (including living in an RV or car/vehicle) and receive a $20 Gift Card.", + "subheaderEs": "Lleve este cupón a una de las siguientes ubicaciones para completar una encuesta sobre su experiencia sin refugio (incluido vivir en un coche) y recibir una tarjeta de regalo de $20.", + "subsubheaderEn": "Language translation (including ASL) will be available. Pets and service animals welcome.", + "subsubheaderEs": "Todas las ubicaciones son accesibles con estacionamiento gratuito y portabicicletas. Habrá traducción de idiomas (incluido ASL) disponible. Mascotas y animales de servicio son bienvenidos.", + "warningEn": "Survey data results will be used by the King County Continuum of Care (CoC) to estimate the number of people living unsheltered in King County. Find out more at kcrha.org/pit.", + "warningEs": "Los resultados de la encuesta serán utilizados por el King County Continuum of Care (CoC) para estimar el número de personas viviendo sin refugio en King County. Descubre más en kcrha.org/pit", + "locations": [ + { + "name": "Renton Highlands Library", + "address": "2801 NE 10th St, Renton, WA 98056", + "daysEn": "February 2-6 (Monday-Friday)", + "daysEs": "2-6 de febrero (lunes-viernes)", + "hoursEn": "Mon/Thu/Fri 1:30pm to 5:30pm, Tue/Wed 2:30pm to 6:30pm ", + "hoursEs": "lun/juev/vier 1:30pm - 5:30pm, mar//miérc - 2:30pm - 6:30pm " + }, + { + "name": "Kent Library", + "address": "212 2nd Ave N, Kent, WA 98032", + "daysEn": "January 26-30 (Monday-Friday)", + "daysEs": "26-30 de enero (lunes-viernes)", + "hoursEn": "12pm to 4pm, Thursday 12pm to 6pm", + "hoursEs": "12pm - 4pm, Jueves 12pm - 6pm" + }, + { + "name": "Highline United Methodist Church", + "address": "13015 1st Ave S, Burien, WA 98168", + "daysEn": "Januray 27-28, February 2-4", + "daysEs": "27-28 de enero, 2-4 de febrero", + "hoursEn": "10am to 2pm", + "hoursEs": "10am - 2pm" + }, + { + "name": "Federal Way Library", + "address": "34200 1st Way S, Federal Way, WA 98003", + "daysEn": "February 2-3, 5-6 (Monday-Tuesday, Thursday-Friday)", + "daysEs": "2-3, 5-6 de febrero (lunes-martes, jueves-viernes)", + "hoursEn": "11am to 5pm", + "hoursEs": "11am - 5pm" + } + ] + }, + "vashon" : { + "headerEn": "Vashon Survey Locations", + "headerEs": "Ubicaciones en Vashon", + "subheaderEn": "Bring this coupon to one of the locations below during a date and time listed. Complete a survey about your experience being unsheltered (including living in an RV or car/vehicle) and receive a $20 Gift Card.", + "subheaderEs": "Lleve este cupón a una de las siguientes ubicaciones para completar una encuesta sobre su experiencia sin refugio (incluido vivir en un coche) y recibir una tarjeta de regalo de $20.", + "subsubheaderEn": "Language translation (including ASL) will be available. Pets and service animals welcome.", + "subsubheaderEs": "Todas las ubicaciones son accesibles con estacionamiento gratuito y portabicicletas. Habrá traducción de idiomas (incluido ASL) disponible. Mascotas y animales de servicio son bienvenidos.", + "warningEn": "Survey data results will be used by the King County Continuum of Care (CoC) to estimate the number of people living unsheltered in King County. Find out more at kcrha.org/pit.", + "warningEs": "Los resultados de la encuesta serán utilizados por el King County Continuum of Care (CoC) para estimar el número de personas viviendo sin refugio en King County. Descubre más en kcrha.org/pit", + "locations": [ + + { + "name": "Vashon Island Library", + "address": "17210 Vashon Hwy SW, Vashon, WA 98070", + "daysEn": "January 26, 27, 29 (Monday, Tuesday, Thursday)", + "daysEs": "26, 27, 29 de enero (lunes, martes, jueves)", + "hoursEn": "on/Thu 11:30am to 2:30pm, Tue 12:30pm to 2:30pm", + "hoursEs": "lun/juev 11:30pm - 2:30pm, mar 12:30pm - 2:30pm" + }, + { + "name": "Vashon Maury Community Food Bank", + "address": "10030 SW 210 St Vashon, WA 98070", + "daysEn": "January 28 (Wednesday), February 4 (Wednesday)", + "daysEs": "28 de enero (miércoles), 4 de febrero (miércoles)", + "hoursEn": "10:30am - 1pm", + "hoursEs": "10:30am - 1pm" + } + + ] + } +}