From 49ef440d6d9bac25f97ed9b589f4b84a001c651c Mon Sep 17 00:00:00 2001 From: ihsankahveci Date: Tue, 20 Jan 2026 17:48:03 -0800 Subject: [PATCH 1/9] working on creating region specific seed templates, reading locations from JSON --- scripts/generateSeeds.ts | 784 ++++++++++++++++++++++++++++++++++++ scripts/seed_templates.json | 154 +++++++ 2 files changed, 938 insertions(+) create mode 100644 scripts/generateSeeds.ts create mode 100644 scripts/seed_templates.json diff --git a/scripts/generateSeeds.ts b/scripts/generateSeeds.ts new file mode 100644 index 0000000..f368dd3 --- /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 0000000..a8d45fb --- /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" + } + ] + } +} From 40d0e386ab1f94b705b7470b7ccba5d743a94ce1 Mon Sep 17 00:00:00 2001 From: ihsankahveci Date: Tue, 20 Jan 2026 21:14:06 -0800 Subject: [PATCH 2/9] Add seed templates for survey locations across King County with multilingual support. --- server/src/scripts/assets/kcrha_logo.png | Bin 0 -> 50376 bytes server/src/scripts/assets/uw_logo.png | Bin 0 -> 4309 bytes server/src/scripts/generateSeeds.ts | 742 ++++++++++++----------- server/src/scripts/seed_templates.json | 292 +++++++++ 4 files changed, 687 insertions(+), 347 deletions(-) create mode 100644 server/src/scripts/assets/kcrha_logo.png create mode 100644 server/src/scripts/assets/uw_logo.png create mode 100644 server/src/scripts/seed_templates.json diff --git a/server/src/scripts/assets/kcrha_logo.png b/server/src/scripts/assets/kcrha_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..c57097a5b375279222242b046aa34f5c16babf09 GIT binary patch literal 50376 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 literal 0 HcmV?d00001 diff --git a/server/src/scripts/assets/uw_logo.png b/server/src/scripts/assets/uw_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..8433be789d095343fc266920b7aacca01371d8d8 GIT binary patch literal 4309 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 literal 0 HcmV?d00001 diff --git a/server/src/scripts/generateSeeds.ts b/server/src/scripts/generateSeeds.ts index 955843a..f5e1b1a 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(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 = `kcrha-pit-count-seeds-${sanitizedLocationName}-${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 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'); +// ===== 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 Referral 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(`Referral 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[], locationName: string, templateKey: string = 'seattle'): 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) - ); + 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, 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) { - 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 0000000..f720635 --- /dev/null +++ b/server/src/scripts/seed_templates.json @@ -0,0 +1,292 @@ +{ + "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. Language translation (including ASL) will be available. Pets and service animals welcome.", + "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. 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. Language translation (including ASL) will be available. Pets and service animals welcome.", + "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. 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. Language translation (including ASL) will be available. Pets and service animals welcome.", + "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. 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. Language translation (including ASL) will be available. Pets and service animals welcome.", + "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. 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. Language translation (including ASL) will be available. Pets and service animals welcome.", + "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. 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. Language translation (including ASL) will be available. Pets and service animals welcome.", + "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. 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": "17210 Vashon Hwy SW, Vashon, WA 98070", + "daysEn": "January 28 (Wednesday)", + "daysEs": "28 de enero (miércoles)", + "hoursEn": "10:30am to 3pm", + "hoursEs": "10:30am - 3pm" + } + + ] + } +} From 08a534e71bfd4bcecd47f9735c79b5e492a26c52 Mon Sep 17 00:00:00 2001 From: ihsankahveci Date: Tue, 20 Jan 2026 21:14:18 -0800 Subject: [PATCH 3/9] Remove logo.png asset from project --- server/src/scripts/assets/logo.png | Bin 4309 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 server/src/scripts/assets/logo.png diff --git a/server/src/scripts/assets/logo.png b/server/src/scripts/assets/logo.png deleted file mode 100644 index 8433be789d095343fc266920b7aacca01371d8d8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4309 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 From f2ceac3e17affa91d2a5bc32443560c87c99adb2 Mon Sep 17 00:00:00 2001 From: ihsankahveci Date: Thu, 22 Jan 2026 08:05:11 -0800 Subject: [PATCH 4/9] Update KCRHA PIT Count PDF generation with improved layout and standardized templates - Replace KCRHA text header with logo image (kcrha_logo.png) - Improve PDF layout spacing and positioning - Use Math.min for text/QR positioning to bring locations closer to top - Position footer at bottom of page for consistent layout - Update PDF filename format to: kcrha-pit-2026-{templatekey}-{count}-seeds-{timestamp}.pdf - Standardize all template regions with consistent subheader/subsubheader structure - Split long subheaders into separate subheader and subsubheader fields - Add subsubheaderEn/Es to all templates (north, east, snoqualmie valley, southeast, south, Vashon) - Clean up trailing spaces in template headers Co-Authored-By: Claude Sonnet 4.5 --- server/src/scripts/generateSeeds.ts | 12 ++++---- server/src/scripts/seed_templates.json | 42 +++++++++++++++++--------- 2 files changed, 33 insertions(+), 21 deletions(-) diff --git a/server/src/scripts/generateSeeds.ts b/server/src/scripts/generateSeeds.ts index f5e1b1a..8fa6002 100644 --- a/server/src/scripts/generateSeeds.ts +++ b/server/src/scripts/generateSeeds.ts @@ -68,7 +68,7 @@ function createOutputDirectory(): string { return outputDir; } -function generateTimestampFilename(locationName: string, outputDir: string): string { +function generateTimestampFilename(templateKey: string, count: number, outputDir: string): string { const now = new Date(); const timestamp = [ now.getFullYear(), @@ -78,8 +78,8 @@ function generateTimestampFilename(locationName: string, outputDir: string): str String(now.getMinutes()).padStart(2, '0'), String(now.getSeconds()).padStart(2, '0') ].join(''); - const sanitizedLocationName = locationName.replace(/[^a-z0-9]/gi, '-').toLowerCase(); - const filename = `kcrha-pit-count-seeds-${sanitizedLocationName}-${timestamp}.pdf`; + const sanitizedTemplateKey = templateKey.replace(/[^a-z0-9]/gi, '-').toLowerCase(); + const filename = `kcrha-pit-2026-${sanitizedTemplateKey}-${count}-seeds-${timestamp}.pdf`; return path.join(outputDir, filename); } @@ -273,9 +273,9 @@ async function addSpanishPage(doc: any, surveyCode: string, template: LocationTe .text(template.warningEs, margin, footerY, { width: contentWidth, align: 'left', lineGap: 2 }); } -async function generatePDF(seeds: any[], locationName: string, templateKey: string = 'seattle'): Promise { +async function generatePDF(seeds: any[], templateKey: string = 'seattle'): Promise { const outputDir = createOutputDirectory(); - const filepath = generateTimestampFilename(locationName, outputDir); + const filepath = generateTimestampFilename(templateKey, seeds.length, outputDir); // Load and get the location template const templates = loadTemplates(); @@ -403,7 +403,7 @@ async function generateSeeds(locationIdentifier: string, count: number, template printSeedsSummary(createdSeeds, location.hubName); console.log('\n📄 Generating PDF with QR codes...'); - await generatePDF(createdSeeds, location.hubName, templateKey); + await generatePDF(createdSeeds, templateKey); } catch (error) { console.error('\n✗ Error:', error instanceof Error ? error.message : error); process.exit(1); diff --git a/server/src/scripts/seed_templates.json b/server/src/scripts/seed_templates.json index f720635..3ac1289 100644 --- a/server/src/scripts/seed_templates.json +++ b/server/src/scripts/seed_templates.json @@ -94,8 +94,10 @@ "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. Language translation (including ASL) will be available. Pets and service animals welcome.", - "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. 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.", + "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": [ @@ -127,9 +129,11 @@ }, "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. Language translation (including ASL) will be available. Pets and service animals welcome.", - "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. 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.", + "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": [ @@ -170,8 +174,10 @@ "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. Language translation (including ASL) will be available. Pets and service animals welcome.", - "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. 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.", + "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": [ @@ -195,9 +201,11 @@ }, "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. Language translation (including ASL) will be available. Pets and service animals welcome.", - "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. 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.", + "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": [ @@ -221,9 +229,11 @@ }, "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. Language translation (including ASL) will be available. Pets and service animals welcome.", - "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. 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.", + "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": [ @@ -264,8 +274,10 @@ "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. Language translation (including ASL) will be available. Pets and service animals welcome.", - "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. 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.", + "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": [ From eff35b7b5a7473bc694196aba6ddc7770d1f2789 Mon Sep 17 00:00:00 2001 From: ihsankahveci Date: Tue, 20 Jan 2026 17:48:03 -0800 Subject: [PATCH 5/9] working on creating region specific seed templates, reading locations from JSON --- scripts/generateSeeds.ts | 784 ++++++++++++++++++++++++++++++++++++ scripts/seed_templates.json | 154 +++++++ 2 files changed, 938 insertions(+) create mode 100644 scripts/generateSeeds.ts create mode 100644 scripts/seed_templates.json diff --git a/scripts/generateSeeds.ts b/scripts/generateSeeds.ts new file mode 100644 index 0000000..f368dd3 --- /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 0000000..a8d45fb --- /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" + } + ] + } +} From a345153bca21698765270c8c4734fa07bf5955ea Mon Sep 17 00:00:00 2001 From: ihsankahveci Date: Tue, 20 Jan 2026 21:14:06 -0800 Subject: [PATCH 6/9] Add seed templates for survey locations across King County with multilingual support. --- server/src/scripts/assets/kcrha_logo.png | Bin 0 -> 50376 bytes server/src/scripts/assets/uw_logo.png | Bin 0 -> 4309 bytes server/src/scripts/generateSeeds.ts | 742 ++++++++++++----------- server/src/scripts/seed_templates.json | 292 +++++++++ 4 files changed, 687 insertions(+), 347 deletions(-) create mode 100644 server/src/scripts/assets/kcrha_logo.png create mode 100644 server/src/scripts/assets/uw_logo.png create mode 100644 server/src/scripts/seed_templates.json diff --git a/server/src/scripts/assets/kcrha_logo.png b/server/src/scripts/assets/kcrha_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..c57097a5b375279222242b046aa34f5c16babf09 GIT binary patch literal 50376 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 literal 0 HcmV?d00001 diff --git a/server/src/scripts/assets/uw_logo.png b/server/src/scripts/assets/uw_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..8433be789d095343fc266920b7aacca01371d8d8 GIT binary patch literal 4309 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 literal 0 HcmV?d00001 diff --git a/server/src/scripts/generateSeeds.ts b/server/src/scripts/generateSeeds.ts index 8cdf5f8..f5e1b1a 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(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 = `kcrha-pit-count-seeds-${sanitizedLocationName}-${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[], locationName: string, templateKey: string = 'seattle'): 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) - ); + 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, 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) { - 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 0000000..f720635 --- /dev/null +++ b/server/src/scripts/seed_templates.json @@ -0,0 +1,292 @@ +{ + "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. Language translation (including ASL) will be available. Pets and service animals welcome.", + "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. 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. Language translation (including ASL) will be available. Pets and service animals welcome.", + "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. 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. Language translation (including ASL) will be available. Pets and service animals welcome.", + "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. 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. Language translation (including ASL) will be available. Pets and service animals welcome.", + "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. 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. Language translation (including ASL) will be available. Pets and service animals welcome.", + "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. 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. Language translation (including ASL) will be available. Pets and service animals welcome.", + "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. 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": "17210 Vashon Hwy SW, Vashon, WA 98070", + "daysEn": "January 28 (Wednesday)", + "daysEs": "28 de enero (miércoles)", + "hoursEn": "10:30am to 3pm", + "hoursEs": "10:30am - 3pm" + } + + ] + } +} From 0ff6cf0094bf20682cce59969a55fafc56803af0 Mon Sep 17 00:00:00 2001 From: ihsankahveci Date: Tue, 20 Jan 2026 21:14:18 -0800 Subject: [PATCH 7/9] Remove logo.png asset from project --- server/src/scripts/assets/logo.png | Bin 4309 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 server/src/scripts/assets/logo.png diff --git a/server/src/scripts/assets/logo.png b/server/src/scripts/assets/logo.png deleted file mode 100644 index 8433be789d095343fc266920b7aacca01371d8d8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4309 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 From ff0fe1533843f9c97eb90af13955afe617489fec Mon Sep 17 00:00:00 2001 From: ihsankahveci Date: Thu, 22 Jan 2026 08:05:11 -0800 Subject: [PATCH 8/9] Update KCRHA PIT Count PDF generation with improved layout and standardized templates - Replace KCRHA text header with logo image (kcrha_logo.png) - Improve PDF layout spacing and positioning - Use Math.min for text/QR positioning to bring locations closer to top - Position footer at bottom of page for consistent layout - Update PDF filename format to: kcrha-pit-2026-{templatekey}-{count}-seeds-{timestamp}.pdf - Standardize all template regions with consistent subheader/subsubheader structure - Split long subheaders into separate subheader and subsubheader fields - Add subsubheaderEn/Es to all templates (north, east, snoqualmie valley, southeast, south, Vashon) - Clean up trailing spaces in template headers Co-Authored-By: Claude Sonnet 4.5 --- server/src/scripts/generateSeeds.ts | 12 ++++---- server/src/scripts/seed_templates.json | 42 +++++++++++++++++--------- 2 files changed, 33 insertions(+), 21 deletions(-) diff --git a/server/src/scripts/generateSeeds.ts b/server/src/scripts/generateSeeds.ts index f5e1b1a..8fa6002 100644 --- a/server/src/scripts/generateSeeds.ts +++ b/server/src/scripts/generateSeeds.ts @@ -68,7 +68,7 @@ function createOutputDirectory(): string { return outputDir; } -function generateTimestampFilename(locationName: string, outputDir: string): string { +function generateTimestampFilename(templateKey: string, count: number, outputDir: string): string { const now = new Date(); const timestamp = [ now.getFullYear(), @@ -78,8 +78,8 @@ function generateTimestampFilename(locationName: string, outputDir: string): str String(now.getMinutes()).padStart(2, '0'), String(now.getSeconds()).padStart(2, '0') ].join(''); - const sanitizedLocationName = locationName.replace(/[^a-z0-9]/gi, '-').toLowerCase(); - const filename = `kcrha-pit-count-seeds-${sanitizedLocationName}-${timestamp}.pdf`; + const sanitizedTemplateKey = templateKey.replace(/[^a-z0-9]/gi, '-').toLowerCase(); + const filename = `kcrha-pit-2026-${sanitizedTemplateKey}-${count}-seeds-${timestamp}.pdf`; return path.join(outputDir, filename); } @@ -273,9 +273,9 @@ async function addSpanishPage(doc: any, surveyCode: string, template: LocationTe .text(template.warningEs, margin, footerY, { width: contentWidth, align: 'left', lineGap: 2 }); } -async function generatePDF(seeds: any[], locationName: string, templateKey: string = 'seattle'): Promise { +async function generatePDF(seeds: any[], templateKey: string = 'seattle'): Promise { const outputDir = createOutputDirectory(); - const filepath = generateTimestampFilename(locationName, outputDir); + const filepath = generateTimestampFilename(templateKey, seeds.length, outputDir); // Load and get the location template const templates = loadTemplates(); @@ -403,7 +403,7 @@ async function generateSeeds(locationIdentifier: string, count: number, template printSeedsSummary(createdSeeds, location.hubName); console.log('\n📄 Generating PDF with QR codes...'); - await generatePDF(createdSeeds, location.hubName, templateKey); + await generatePDF(createdSeeds, templateKey); } catch (error) { console.error('\n✗ Error:', error instanceof Error ? error.message : error); process.exit(1); diff --git a/server/src/scripts/seed_templates.json b/server/src/scripts/seed_templates.json index f720635..3ac1289 100644 --- a/server/src/scripts/seed_templates.json +++ b/server/src/scripts/seed_templates.json @@ -94,8 +94,10 @@ "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. Language translation (including ASL) will be available. Pets and service animals welcome.", - "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. 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.", + "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": [ @@ -127,9 +129,11 @@ }, "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. Language translation (including ASL) will be available. Pets and service animals welcome.", - "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. 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.", + "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": [ @@ -170,8 +174,10 @@ "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. Language translation (including ASL) will be available. Pets and service animals welcome.", - "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. 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.", + "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": [ @@ -195,9 +201,11 @@ }, "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. Language translation (including ASL) will be available. Pets and service animals welcome.", - "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. 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.", + "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": [ @@ -221,9 +229,11 @@ }, "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. Language translation (including ASL) will be available. Pets and service animals welcome.", - "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. 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.", + "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": [ @@ -264,8 +274,10 @@ "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. Language translation (including ASL) will be available. Pets and service animals welcome.", - "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. 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.", + "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": [ From 4b6c5a4c28122b4b9ac6bc3ae2282d62adc2eeb9 Mon Sep 17 00:00:00 2001 From: ihsankahveci Date: Sat, 31 Jan 2026 15:43:53 -0800 Subject: [PATCH 9/9] updated vashon template --- server/src/scripts/seed_templates.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/server/src/scripts/seed_templates.json b/server/src/scripts/seed_templates.json index 3ac1289..9c621d0 100644 --- a/server/src/scripts/seed_templates.json +++ b/server/src/scripts/seed_templates.json @@ -271,7 +271,7 @@ } ] }, - "Vashon" : { + "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.", @@ -292,11 +292,11 @@ }, { "name": "Vashon Maury Community Food Bank", - "address": "17210 Vashon Hwy SW, Vashon, WA 98070", - "daysEn": "January 28 (Wednesday)", - "daysEs": "28 de enero (miércoles)", - "hoursEn": "10:30am to 3pm", - "hoursEs": "10:30am - 3pm" + "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" } ]