feat: add seed scripts and AI worker

Adds scripts/seed.ts for fresh self-hosted installs, scripts/seed-tenant.ts for cloud provisioner use, and scripts/ai-worker.ts adapted from AHCR with improved theme prompts, name filtering, and WORKER_API_KEY guard. Installs tsx as a dev dependency.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-26 12:49:37 -05:00
co-authored by Claude Sonnet 4.6
parent 6ca598b45c
commit c6b7d4d4eb
5 changed files with 664 additions and 2 deletions
+139
View File
@@ -0,0 +1,139 @@
/**
* FosterFlow AI Worker — runs on a machine with local GPU/Ollama access.
* Polls the FosterFlow API for pending AI jobs, processes them through local Ollama,
* and posts results back.
*
* Usage:
* PUBLIC_SITE_URL=https://yourrescue.fosterflow.app WORKER_API_KEY=your-key npx tsx scripts/ai-worker.ts
*
* Environment variables:
* PUBLIC_SITE_URL — FosterFlow base URL (default: http://localhost:5173)
* WORKER_API_KEY — Shared secret for authenticating with the FosterFlow API
* OLLAMA_URL — Ollama API URL (default: http://localhost:11434)
* POLL_INTERVAL — Milliseconds between polls (default: 10000)
*
* NOTE: Old completed/failed jobs should be cleaned up periodically.
* Consider a cron job: DELETE FROM ai_jobs WHERE status IN ('completed','failed') AND completed_at < NOW() - INTERVAL 7 DAY;
*/
const API_URL = process.env.PUBLIC_SITE_URL || 'http://localhost:5173';
const WORKER_KEY = process.env.WORKER_API_KEY || '';
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://localhost:11434';
const POLL_INTERVAL = parseInt(process.env.POLL_INTERVAL || '10000', 10);
if (!WORKER_KEY) {
console.error('ERROR: WORKER_API_KEY environment variable is required');
process.exit(1);
}
async function fetchNextJob() {
const res = await fetch(`${API_URL}/api/worker/jobs`, {
headers: { Authorization: `Bearer ${WORKER_KEY}` }
});
if (!res.ok) return null;
return res.json();
}
async function reportResult(jobId: number, output: unknown) {
await fetch(`${API_URL}/api/worker/jobs/${jobId}`, {
method: 'POST',
headers: { Authorization: `Bearer ${WORKER_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'completed', output })
});
}
async function reportError(jobId: number, error: string) {
await fetch(`${API_URL}/api/worker/jobs/${jobId}`, {
method: 'POST',
headers: { Authorization: `Bearer ${WORKER_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'failed', error })
});
}
async function generateNames(input: { species: string; theme: string; count: number; existingNames?: string }) {
const themes: Record<string, string> = {
food: 'food and cooking themed names (like Biscuit, Waffles, Chimichurri, Mochi, Pancake)',
disney: 'Disney character themed names (like Simba, Nala, Elsa, Olaf, Stitch)',
celebrity: 'celebrity or pop culture themed names (like Bowie, Dolly, Elvis, Beyonce)',
nature: 'nature themed names (like Willow, Storm, River, Daisy, Bear)',
classic: 'classic pet names (like Max, Bella, Charlie, Luna, Cooper)',
funny: 'funny and punny names (like Sir Barks-a-Lot, Bark Twain, Chewbarka, Purrlock Holmes)',
random: 'creative and unique names'
};
const themePrompt = themes[input.theme] || themes.random;
const count = input.count || 10;
const prompt = `Generate exactly ${count} creative ${input.species || 'dog'} names. Theme: ${themePrompt}.
IMPORTANT: These names are ALREADY TAKEN and must NOT be reused: ${input.existingNames || 'none'}
Rules:
- Just the names, one per line
- No numbers, no explanations, no extra text
- Be creative and fun
- Mix single names and two-word names
- Do NOT repeat any name from the taken list above`;
const res = await fetch(`${OLLAMA_URL}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'gemma2:9b',
prompt,
stream: false,
options: {
temperature: 1.2,
top_p: 0.95,
num_predict: 200
}
})
});
if (!res.ok) throw new Error(`Ollama error: ${res.status}: ${await res.text()}`);
const data = await res.json();
const names = (data.response || '')
.split('\n')
.map((n: string) => n.replace(/^\d+[\.\)]\s*/, '').replace(/^[-*•]\s*/, '').trim())
.filter((name: string) => name && name.length > 1 && name.length < 40 && !name.includes(':'))
.filter((name: string, index: number, self: string[]) => self.indexOf(name) === index)
.slice(0, count);
return { names };
}
async function poll() {
console.log('FosterFlow AI Worker started');
console.log(` Polling: ${API_URL} every ${POLL_INTERVAL}ms`);
console.log(` Ollama: ${OLLAMA_URL}`);
// Run immediately on start, then on interval
const tick = async () => {
try {
const job = await fetchNextJob();
if (job && job.id) {
console.log(`Processing job ${job.id} (${job.type})`);
try {
if (job.type === 'pet-names') {
const result = await generateNames(job.input);
await reportResult(job.id, result);
console.log(`Job ${job.id} completed`);
} else {
await reportError(job.id, `Unknown job type: ${job.type}`);
}
} catch (err: any) {
await reportError(job.id, err.message);
console.error(`Job ${job.id} failed:`, err.message);
}
}
} catch (err) {
// Connection error — will retry on next interval
}
};
tick();
setInterval(tick, POLL_INTERVAL);
}
poll();
+52
View File
@@ -0,0 +1,52 @@
import { drizzle } from 'drizzle-orm/mysql2';
import mysql from 'mysql2/promise';
import { hash } from '@node-rs/argon2';
import * as schema from '../src/lib/server/schema';
const args = process.argv.slice(2);
const flags: Record<string, string> = {};
for (let i = 0; i < args.length; i += 2) {
flags[args[i].replace('--', '')] = args[i + 1];
}
async function seedTenant() {
const { email, name, password, orgName } = flags;
if (!email || !name || !password || !orgName) {
console.error('Usage: npx tsx scripts/seed-tenant.ts --email X --name X --password X --orgName X');
process.exit(1);
}
const pool = mysql.createPool({ uri: process.env.DATABASE_URL });
const db = drizzle(pool, { schema, mode: 'default' });
const passwordHash = await hash(password);
await db.insert(schema.users).values({
email,
passwordHash,
name,
role: 'sysadmin',
active: true,
mustChangePassword: true
});
const orgConfigDefaults = [
{ key: 'branding', value: { primaryColor: '#0d9488', primaryColorLight: '#14b8a6', primaryColorDark: '#0f766e', accentColor: '#f59e0b', accentColorLight: '#fbbf24', accentColorDark: '#d97706', logoUrl: '' } },
{ key: 'org_info', value: { name: orgName, tagline: '', city: '', state: '', phone: '', email, facebookUrl: '', instagramUrl: '' } },
{ key: 'locale', value: { language: 'en', currency: 'USD', dateFormat: 'MM/DD/YYYY' } },
{ key: 'applications', value: { adoptionEnabled: true, fosterEnabled: true, volunteerEnabled: true, surrenderEnabled: true, adoptionFee: 250 } },
{ key: 'setup_complete', value: false }
];
for (const row of orgConfigDefaults) {
await db.insert(schema.orgConfig).values({ key: row.key, value: row.value });
}
const intakeSources = ['Owner Surrender', 'Stray', 'Transfer', 'Born in Care', 'Other'];
for (const sourceName of intakeSources) {
await db.insert(schema.intakeSources).values({ name: sourceName });
}
console.log(`Tenant seeded: ${email} / [password provided] for ${orgName}`);
await pool.end();
}
seedTenant().catch(console.error);
+63
View File
@@ -0,0 +1,63 @@
import { drizzle } from 'drizzle-orm/mysql2';
import mysql from 'mysql2/promise';
import { hash } from '@node-rs/argon2';
import * as schema from '../src/lib/server/schema';
async function seed() {
const pool = mysql.createPool({ uri: process.env.DATABASE_URL });
const db = drizzle(pool, { schema, mode: 'default' });
console.log('Seeding FosterFlow database...');
// 1. Create admin user
const passwordHash = await hash('changeme');
await db.insert(schema.users).values({
email: 'admin@example.com',
passwordHash,
name: 'Admin',
role: 'sysadmin',
active: true,
mustChangePassword: true
});
console.log('Created admin user (admin@example.com / changeme)');
// 2. Default org config
const orgConfigDefaults = [
{ key: 'branding', value: { primaryColor: '#0d9488', primaryColorLight: '#14b8a6', primaryColorDark: '#0f766e', accentColor: '#f59e0b', accentColorLight: '#fbbf24', accentColorDark: '#d97706', logoUrl: '' } },
{ key: 'org_info', value: { name: 'My Rescue', tagline: '', city: '', state: '', phone: '', email: '', facebookUrl: '', instagramUrl: '' } },
{ key: 'locale', value: { language: 'en', currency: 'USD', dateFormat: 'MM/DD/YYYY' } },
{ key: 'applications', value: { adoptionEnabled: true, fosterEnabled: true, volunteerEnabled: true, surrenderEnabled: true, adoptionFee: 250 } },
{ key: 'setup_complete', value: false }
];
for (const row of orgConfigDefaults) {
await db.insert(schema.orgConfig).values({ key: row.key, value: row.value });
}
console.log('Created default org config');
// 3. Default intake sources
const intakeSources = ['Owner Surrender', 'Stray', 'Transfer', 'Born in Care', 'Other'];
for (const name of intakeSources) {
await db.insert(schema.intakeSources).values({ name });
}
console.log('Created default intake sources');
// 4. Default content blocks (placeholder)
const contentDefaults = [
{ page: 'home', sectionKey: 'hero', content: { title: 'Find Your Forever Friend', subtitle: 'We rescue, rehabilitate, and rehome animals in need.' } },
{ page: 'about', sectionKey: 'intro', content: { text: 'Welcome to our rescue! We are dedicated to saving animals in need.' } },
{ page: 'adoption-process', sectionKey: 'intro', content: { text: 'Our adoption process is designed to find the best match for both you and our animals.' } },
{ page: 'become-a-foster', sectionKey: 'intro', content: { text: 'Fostering saves lives! Learn how you can help by opening your home to an animal in need.' } },
{ page: 'how-to-help', sectionKey: 'intro', content: { text: 'There are many ways you can help our rescue mission.' } },
{ page: 'volunteer', sectionKey: 'intro', content: { text: 'Volunteers are the backbone of our organization.' } },
{ page: 'why-adopt', sectionKey: 'intro', content: { text: 'Adopting a pet changes two lives — yours and theirs.' } }
];
for (const block of contentDefaults) {
await db.insert(schema.contentBlocks).values(block);
}
console.log('Created default content blocks');
console.log('\nDone! Log in at /login with admin@example.com / changeme');
await pool.end();
}
seed().catch(console.error);