Initial commit
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
import Anthropic from '@anthropic-ai/sdk';
|
||||
import { getConfig } from '@/lib/db';
|
||||
|
||||
const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
|
||||
|
||||
export async function getModel(): Promise<string> {
|
||||
return (await getConfig('openai_model')) || 'claude-3-5-sonnet-20241022';
|
||||
}
|
||||
|
||||
export async function generate(
|
||||
systemPrompt: string,
|
||||
userPrompt: string
|
||||
): Promise<{ text: string; tokens: number }> {
|
||||
const model = await getModel();
|
||||
|
||||
const response = await anthropic.messages.create({
|
||||
model,
|
||||
max_tokens: 4096,
|
||||
system: systemPrompt,
|
||||
messages: [{ role: 'user', content: userPrompt }],
|
||||
});
|
||||
|
||||
const text = response.content
|
||||
.filter((b) => b.type === 'text')
|
||||
.map((b) => (b as Anthropic.TextBlock).text)
|
||||
.join('');
|
||||
|
||||
const tokens = (response.usage.input_tokens ?? 0) + (response.usage.output_tokens ?? 0);
|
||||
|
||||
return { text, tokens };
|
||||
}
|
||||
|
||||
export default anthropic;
|
||||
@@ -0,0 +1,64 @@
|
||||
export const funderRewriterMaster = (input: Record<string, string>) => `
|
||||
You are an expert grant writer who specialises in tailoring NGO proposals to specific funders' priorities and language.
|
||||
|
||||
Rewrite the following proposal text to align with the target funder's priorities:
|
||||
|
||||
Original Text:
|
||||
${input.original_text}
|
||||
|
||||
Target Funder: ${input.funder_name}
|
||||
Funder Type: ${input.funder_type}
|
||||
Funder Priorities: ${input.funder_priorities}
|
||||
Grant Size: ${input.grant_size}
|
||||
Sector Focus: ${input.sector}
|
||||
|
||||
Rewrite the text to:
|
||||
1. **Mirror the funder's language** — Use terminology and framing the funder prefers
|
||||
2. **Highlight alignment** — Emphasise how the project meets their specific priorities
|
||||
3. **Adjust tone** — Match the formality level (institutional vs. foundation vs. corporate)
|
||||
4. **Strengthen impact claims** — Frame outcomes in terms the funder values
|
||||
5. **Add strategic keywords** — Include terms from the funder's own strategy documents
|
||||
|
||||
Provide the rewritten text with annotations explaining key changes made and why.
|
||||
`;
|
||||
|
||||
export const funderRewriterImprove = (section: string, feedback: string) => `
|
||||
You are an expert grant writer. Improve the following rewritten section based on feedback.
|
||||
|
||||
Current Rewritten Section:
|
||||
${section}
|
||||
|
||||
Feedback:
|
||||
${feedback}
|
||||
|
||||
Refine the text to better address the feedback while maintaining alignment with the target funder's language and priorities.
|
||||
`;
|
||||
|
||||
export const funderRewriterValidate = (rewrite: string) => `
|
||||
You are a senior grants manager reviewing a funder-tailored proposal rewrite.
|
||||
|
||||
Review this rewritten proposal:
|
||||
${rewrite}
|
||||
|
||||
Assess:
|
||||
1. **Funder Alignment** — Does the language match the target funder's style?
|
||||
2. **Strategic Fit** — Are the funder's priorities clearly addressed?
|
||||
3. **Persuasiveness** — Is the case compelling and well-argued?
|
||||
4. **Authenticity** — Does it feel genuine, not forced or generic?
|
||||
5. **Clarity** — Is the writing clear, concise, and jargon-appropriate?
|
||||
6. **Compliance** — Does it meet typical funder expectations for structure?
|
||||
|
||||
Score each area out of 10 and provide specific improvement suggestions.
|
||||
`;
|
||||
|
||||
export const funderRewriterFinalize = (rewrite: string, improvements: string) => `
|
||||
You are an expert grant writer producing the final funder-tailored version.
|
||||
|
||||
Rewritten proposal:
|
||||
${rewrite}
|
||||
|
||||
Improvements incorporated:
|
||||
${improvements}
|
||||
|
||||
Produce the final, polished version that is ready for submission. Ensure the funder alignment is seamless and the writing is compelling.
|
||||
`;
|
||||
@@ -0,0 +1,24 @@
|
||||
import { ToolName } from '@/lib/types';
|
||||
import { logframeMaster, logframeImprove, logframeValidate, logframeFinalize } from './logframe';
|
||||
import { funderRewriterMaster, funderRewriterImprove, funderRewriterValidate, funderRewriterFinalize } from './funder-rewriter';
|
||||
import { smartIndicatorsMaster, smartIndicatorsImprove, smartIndicatorsValidate, smartIndicatorsFinalize } from './smart-indicators';
|
||||
import { theoryOfChangeMaster, theoryOfChangeImprove, theoryOfChangeValidate, theoryOfChangeFinalize } from './theory-of-change';
|
||||
|
||||
type PromptFn = (input: Record<string, string>) => string;
|
||||
type SectionPromptFn = (section: string, feedback: string) => string;
|
||||
type ValidateFn = (content: string) => string;
|
||||
type FinalizeFn = (content: string, improvements: string) => string;
|
||||
|
||||
interface ToolPrompts {
|
||||
master: PromptFn;
|
||||
improve: SectionPromptFn;
|
||||
validate: ValidateFn;
|
||||
finalize: FinalizeFn;
|
||||
}
|
||||
|
||||
export const prompts: Record<ToolName, ToolPrompts> = {
|
||||
logframe: { master: logframeMaster, improve: logframeImprove, validate: logframeValidate, finalize: logframeFinalize },
|
||||
'funder-rewriter': { master: funderRewriterMaster, improve: funderRewriterImprove, validate: funderRewriterValidate, finalize: funderRewriterFinalize },
|
||||
'smart-indicators': { master: smartIndicatorsMaster, improve: smartIndicatorsImprove, validate: smartIndicatorsValidate, finalize: smartIndicatorsFinalize },
|
||||
'theory-of-change': { master: theoryOfChangeMaster, improve: theoryOfChangeImprove, validate: theoryOfChangeValidate, finalize: theoryOfChangeFinalize },
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
export const logframeMaster = (input: Record<string, string>) => `
|
||||
You are an expert international development consultant specialising in logical frameworks (logframes) for NGO grant applications.
|
||||
|
||||
Create a complete, professional logframe matrix for the following project:
|
||||
|
||||
Project Title: ${input.project_title}
|
||||
Organisation: ${input.organisation}
|
||||
Sector: ${input.sector}
|
||||
Target Beneficiaries: ${input.beneficiaries}
|
||||
Geographic Location: ${input.location}
|
||||
Project Duration: ${input.duration}
|
||||
Budget Range: ${input.budget}
|
||||
Project Summary: ${input.summary}
|
||||
|
||||
Generate a complete logframe with:
|
||||
1. **Goal (Impact)** — The high-level development objective this project contributes to
|
||||
2. **Purpose (Outcome)** — The specific change the project will achieve
|
||||
3. **Outputs** — 3-5 concrete deliverables/results
|
||||
4. **Activities** — 2-3 key activities per output
|
||||
5. **Indicators** — SMART indicators for each level (goal, purpose, outputs)
|
||||
6. **Means of Verification** — How each indicator will be measured
|
||||
7. **Assumptions** — Key assumptions at each level
|
||||
|
||||
Format as a structured logframe matrix using markdown tables. Be specific, measurable, and realistic.
|
||||
`;
|
||||
|
||||
export const logframeImprove = (section: string, feedback: string) => `
|
||||
You are an expert logframe consultant. Improve the following section based on the feedback provided.
|
||||
|
||||
Current Section:
|
||||
${section}
|
||||
|
||||
Feedback:
|
||||
${feedback}
|
||||
|
||||
Provide an improved version that addresses the feedback while maintaining professional quality and logical consistency with a standard logframe matrix.
|
||||
`;
|
||||
|
||||
export const logframeValidate = (logframe: string) => `
|
||||
You are a senior M&E (Monitoring & Evaluation) specialist reviewing a logframe for quality assurance.
|
||||
|
||||
Review this logframe:
|
||||
${logframe}
|
||||
|
||||
Provide a detailed quality assessment covering:
|
||||
1. **Logical Consistency** — Does the causal chain (activities → outputs → purpose → goal) hold?
|
||||
2. **SMART Indicators** — Are indicators Specific, Measurable, Achievable, Relevant, Time-bound?
|
||||
3. **Means of Verification** — Are MOVs realistic and cost-effective?
|
||||
4. **Assumptions** — Are assumptions clearly stated and reasonable?
|
||||
5. **Completeness** — Are there gaps or missing elements?
|
||||
6. **Donor Readiness** — Would this pass scrutiny from major donors (DFID, USAID, EU)?
|
||||
|
||||
Score each area out of 10 and provide specific recommendations for improvement.
|
||||
`;
|
||||
|
||||
export const logframeFinalize = (logframe: string, improvements: string) => `
|
||||
You are an expert logframe consultant producing the final version of a logframe.
|
||||
|
||||
Original logframe:
|
||||
${logframe}
|
||||
|
||||
Improvements and feedback incorporated:
|
||||
${improvements}
|
||||
|
||||
Produce the final, polished logframe matrix. Ensure:
|
||||
- All indicators are fully SMART
|
||||
- Means of verification are practical and specific
|
||||
- Assumptions are clearly articulated
|
||||
- The logical chain is coherent and compelling
|
||||
- Language is professional and donor-ready
|
||||
- Format is clean markdown with proper table structure
|
||||
`;
|
||||
@@ -0,0 +1,73 @@
|
||||
export const smartIndicatorsMaster = (input: Record<string, string>) => `
|
||||
You are an M&E (Monitoring & Evaluation) specialist who designs SMART indicators for NGO programmes.
|
||||
|
||||
Design a comprehensive set of SMART indicators for:
|
||||
|
||||
Project/Programme: ${input.project_title}
|
||||
Sector: ${input.sector}
|
||||
Outcomes to Measure: ${input.outcomes}
|
||||
Target Population: ${input.target_population}
|
||||
Timeframe: ${input.timeframe}
|
||||
Data Collection Capacity: ${input.data_capacity}
|
||||
|
||||
Generate:
|
||||
1. **Impact Indicators** (2-3) — Long-term change indicators
|
||||
2. **Outcome Indicators** (3-5) — Medium-term results indicators
|
||||
3. **Output Indicators** (5-8) — Direct deliverable indicators
|
||||
4. **Process Indicators** (3-4) — Implementation quality indicators
|
||||
|
||||
For each indicator provide:
|
||||
- **Indicator statement** (precise, measurable wording)
|
||||
- **Baseline** (suggested baseline approach)
|
||||
- **Target** (realistic target with timeframe)
|
||||
- **Data source** (where/how to collect)
|
||||
- **Frequency** (how often to measure)
|
||||
- **Disaggregation** (gender, age, location, etc.)
|
||||
|
||||
Format as a clear structured table.
|
||||
`;
|
||||
|
||||
export const smartIndicatorsImprove = (section: string, feedback: string) => `
|
||||
You are an M&E specialist. Improve these indicators based on feedback.
|
||||
|
||||
Current Indicators:
|
||||
${section}
|
||||
|
||||
Feedback:
|
||||
${feedback}
|
||||
|
||||
Refine the indicators to be more SMART (Specific, Measurable, Achievable, Relevant, Time-bound) based on the feedback.
|
||||
`;
|
||||
|
||||
export const smartIndicatorsValidate = (indicators: string) => `
|
||||
You are a senior M&E advisor reviewing a set of SMART indicators.
|
||||
|
||||
Review these indicators:
|
||||
${indicators}
|
||||
|
||||
Assess each indicator against SMART criteria:
|
||||
1. **Specific** — Is it clear what is being measured?
|
||||
2. **Measurable** — Can it be quantified or objectively verified?
|
||||
3. **Achievable** — Is the target realistic given context?
|
||||
4. **Relevant** — Does it measure what matters for the programme?
|
||||
5. **Time-bound** — Is there a clear timeframe?
|
||||
|
||||
Also assess:
|
||||
6. **Data feasibility** — Can the data realistically be collected?
|
||||
7. **Completeness** — Are all key results areas covered?
|
||||
8. **Disaggregation** — Is equity adequately captured?
|
||||
|
||||
Score each indicator and provide specific improvement recommendations.
|
||||
`;
|
||||
|
||||
export const smartIndicatorsFinalize = (indicators: string, improvements: string) => `
|
||||
You are an M&E specialist producing the final indicator framework.
|
||||
|
||||
Draft indicators:
|
||||
${indicators}
|
||||
|
||||
Improvements to incorporate:
|
||||
${improvements}
|
||||
|
||||
Produce the final, polished indicator framework ready for donor submission. Ensure all indicators are fully SMART and data collection is feasible.
|
||||
`;
|
||||
@@ -0,0 +1,69 @@
|
||||
export const theoryOfChangeMaster = (input: Record<string, string>) => `
|
||||
You are a Theory of Change specialist who helps NGOs articulate their impact pathways.
|
||||
|
||||
Develop a comprehensive Theory of Change for:
|
||||
|
||||
Organisation/Project: ${input.project_title}
|
||||
Mission: ${input.mission}
|
||||
Problem Statement: ${input.problem}
|
||||
Target Population: ${input.target_population}
|
||||
Geographic Context: ${input.context}
|
||||
Timeframe: ${input.timeframe}
|
||||
|
||||
Generate a complete Theory of Change including:
|
||||
|
||||
1. **Problem Analysis** — Root causes and contributing factors
|
||||
2. **Long-term Change (Impact)** — The ultimate vision of change
|
||||
3. **Medium-term Outcomes** (3-4) — Changes that lead to impact
|
||||
4. **Short-term Outcomes** (4-6) — Early changes from activities
|
||||
5. **Outputs** — What the project directly produces
|
||||
6. **Activities/Interventions** — What the project does
|
||||
7. **Inputs** — Resources required
|
||||
8. **Assumptions** — What must hold true at each level
|
||||
9. **Evidence Base** — What evidence supports each causal link
|
||||
10. **Causal Pathways** — Clear narrative connecting each level
|
||||
|
||||
Format with clear headers and a narrative that explains the causal logic. Include a text-based pathway diagram.
|
||||
`;
|
||||
|
||||
export const theoryOfChangeImprove = (section: string, feedback: string) => `
|
||||
You are a Theory of Change specialist. Improve this section based on feedback.
|
||||
|
||||
Current Section:
|
||||
${section}
|
||||
|
||||
Feedback:
|
||||
${feedback}
|
||||
|
||||
Strengthen the causal logic, evidence base, and clarity based on the feedback provided.
|
||||
`;
|
||||
|
||||
export const theoryOfChangeValidate = (toc: string) => `
|
||||
You are a senior programme strategist reviewing a Theory of Change.
|
||||
|
||||
Review this Theory of Change:
|
||||
${toc}
|
||||
|
||||
Assess:
|
||||
1. **Causal Logic** — Are the pathways from activities to impact clear and plausible?
|
||||
2. **Evidence Base** — Is there evidence supporting the causal assumptions?
|
||||
3. **Assumptions** — Are assumptions explicit, testable, and reasonable?
|
||||
4. **Completeness** — Are all levels fully articulated?
|
||||
5. **Coherence** — Does everything fit together logically?
|
||||
6. **Testability** — Could this ToC be tested/validated through M&E?
|
||||
7. **Stakeholder Perspective** — Are affected communities reflected?
|
||||
|
||||
Score each area out of 10 and provide specific recommendations.
|
||||
`;
|
||||
|
||||
export const theoryOfChangeFinalize = (toc: string, improvements: string) => `
|
||||
You are a Theory of Change specialist producing the final version.
|
||||
|
||||
Draft Theory of Change:
|
||||
${toc}
|
||||
|
||||
Improvements to incorporate:
|
||||
${improvements}
|
||||
|
||||
Produce the final, polished Theory of Change ready for donor submission. Ensure causal pathways are clear, assumptions explicit, and the narrative compelling.
|
||||
`;
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { getSession } from '@auth0/nextjs-auth0';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function requireAdmin(): Promise<NextResponse | null> {
|
||||
const session = await getSession();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Pool } from 'pg';
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
max: 10,
|
||||
idleTimeoutMillis: 30000,
|
||||
});
|
||||
|
||||
export async function query<T = Record<string, unknown>>(
|
||||
text: string,
|
||||
params?: unknown[]
|
||||
): Promise<T[]> {
|
||||
const result = await pool.query(text, params);
|
||||
return result.rows as T[];
|
||||
}
|
||||
|
||||
export async function getConfig(key: string): Promise<string | null> {
|
||||
const rows = await query<{ value: string }>(
|
||||
'SELECT value FROM admin_config WHERE key = $1',
|
||||
[key]
|
||||
);
|
||||
return rows[0]?.value ?? null;
|
||||
}
|
||||
|
||||
export async function getAllConfig(): Promise<Record<string, string>> {
|
||||
const rows = await query<{ key: string; value: string }>(
|
||||
'SELECT key, value FROM admin_config ORDER BY key'
|
||||
);
|
||||
return Object.fromEntries(rows.map((r) => [r.key, r.value]));
|
||||
}
|
||||
|
||||
export async function setConfig(key: string, value: string): Promise<void> {
|
||||
await query(
|
||||
`INSERT INTO admin_config (key, value, updated_at) VALUES ($1, $2, NOW())
|
||||
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()`,
|
||||
[key, value]
|
||||
);
|
||||
}
|
||||
|
||||
export default pool;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { Pool } from 'pg';
|
||||
import { join } from 'path';
|
||||
|
||||
async function migrate() {
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
const sql = readFileSync(join(__dirname, 'schema.sql'), 'utf-8');
|
||||
await pool.query(sql);
|
||||
console.log('✅ Migration complete');
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
migrate().catch((e) => {
|
||||
console.error('Migration failed:', e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
-- NGO Toolkit Lab — Database Schema
|
||||
-- Run: psql $DATABASE_URL < lib/db/schema.sql
|
||||
|
||||
CREATE TABLE IF NOT EXISTS admin_config (
|
||||
key VARCHAR(100) PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS leads (
|
||||
id SERIAL PRIMARY KEY,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
org_name VARCHAR(255),
|
||||
tool VARCHAR(50) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
-- One free generation per email globally
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS leads_email_unique ON leads(email);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tool VARCHAR(50) NOT NULL,
|
||||
email VARCHAR(255),
|
||||
input JSONB NOT NULL DEFAULT '{}',
|
||||
output TEXT,
|
||||
is_paid BOOLEAN DEFAULT FALSE,
|
||||
payment_id VARCHAR(255),
|
||||
improvements_used INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS usage (
|
||||
id SERIAL PRIMARY KEY,
|
||||
session_id UUID REFERENCES sessions(id),
|
||||
tool VARCHAR(50) NOT NULL,
|
||||
action VARCHAR(50) NOT NULL,
|
||||
tokens_used INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Default config
|
||||
INSERT INTO admin_config (key, value) VALUES
|
||||
('price_currency', 'EUR'),
|
||||
('free_tier_enabled', 'true'),
|
||||
('cap_improve', '10'),
|
||||
('cap_analyse', '3'),
|
||||
('openai_model', 'claude-3-5-sonnet-20241022'), -- rename key kept for compat, value is Claude model
|
||||
('lemonsqueezy_store_id', ''),
|
||||
-- Per-tool prices (cents) and LemonSqueezy variant IDs
|
||||
('price_logframe', '900'),
|
||||
('variant_logframe', ''),
|
||||
('price_funder-rewriter', '900'),
|
||||
('variant_funder-rewriter', ''),
|
||||
('price_smart-indicators', '900'),
|
||||
('variant_smart-indicators', ''),
|
||||
('price_theory-of-change', '900'),
|
||||
('variant_theory-of-change', '')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_tool ON sessions(tool);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_email ON sessions(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_leads_email ON leads(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_session ON usage(session_id);
|
||||
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
Document,
|
||||
Paragraph,
|
||||
TextRun,
|
||||
HeadingLevel,
|
||||
Packer,
|
||||
AlignmentType,
|
||||
} from 'docx';
|
||||
|
||||
export async function generateDocx(
|
||||
content: string,
|
||||
toolName: string
|
||||
): Promise<Buffer> {
|
||||
const lines = content.split('\n');
|
||||
const children: Paragraph[] = [];
|
||||
|
||||
// Title
|
||||
children.push(
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({
|
||||
text: `NGO Toolkit Lab — ${toolName.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())}`,
|
||||
bold: true,
|
||||
size: 32,
|
||||
color: '1a6b3c',
|
||||
}),
|
||||
],
|
||||
heading: HeadingLevel.TITLE,
|
||||
alignment: AlignmentType.CENTER,
|
||||
spacing: { after: 400 },
|
||||
})
|
||||
);
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
children.push(new Paragraph({ spacing: { after: 100 } }));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('### ')) {
|
||||
children.push(
|
||||
new Paragraph({
|
||||
children: [new TextRun({ text: trimmed.slice(4), bold: true, size: 24 })],
|
||||
heading: HeadingLevel.HEADING_3,
|
||||
spacing: { before: 200, after: 100 },
|
||||
})
|
||||
);
|
||||
} else if (trimmed.startsWith('## ')) {
|
||||
children.push(
|
||||
new Paragraph({
|
||||
children: [new TextRun({ text: trimmed.slice(3), bold: true, size: 28, color: '1a6b3c' })],
|
||||
heading: HeadingLevel.HEADING_2,
|
||||
spacing: { before: 300, after: 150 },
|
||||
})
|
||||
);
|
||||
} else if (trimmed.startsWith('# ')) {
|
||||
children.push(
|
||||
new Paragraph({
|
||||
children: [new TextRun({ text: trimmed.slice(2), bold: true, size: 32, color: '1a6b3c' })],
|
||||
heading: HeadingLevel.HEADING_1,
|
||||
spacing: { before: 400, after: 200 },
|
||||
})
|
||||
);
|
||||
} else if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) {
|
||||
children.push(
|
||||
new Paragraph({
|
||||
children: [new TextRun({ text: trimmed.slice(2), size: 22 })],
|
||||
bullet: { level: 0 },
|
||||
})
|
||||
);
|
||||
} else if (trimmed.startsWith('**') && trimmed.endsWith('**')) {
|
||||
children.push(
|
||||
new Paragraph({
|
||||
children: [new TextRun({ text: trimmed.slice(2, -2), bold: true, size: 22 })],
|
||||
})
|
||||
);
|
||||
} else {
|
||||
children.push(
|
||||
new Paragraph({
|
||||
children: [new TextRun({ text: trimmed, size: 22 })],
|
||||
spacing: { after: 100 },
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Footer
|
||||
children.push(
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({
|
||||
text: 'Generated by NGO Toolkit Lab — ngotoolkit.com',
|
||||
italics: true,
|
||||
size: 18,
|
||||
color: '888888',
|
||||
}),
|
||||
],
|
||||
alignment: AlignmentType.CENTER,
|
||||
spacing: { before: 600 },
|
||||
})
|
||||
);
|
||||
|
||||
const doc = new Document({
|
||||
sections: [{ children }],
|
||||
});
|
||||
|
||||
return Buffer.from(await Packer.toBuffer(doc));
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
export async function generateExcel(
|
||||
content: string,
|
||||
toolName: string
|
||||
): Promise<Buffer> {
|
||||
const wb = XLSX.utils.book_new();
|
||||
|
||||
// Parse markdown tables if present
|
||||
const lines = content.split('\n');
|
||||
const tableRows: string[][] = [];
|
||||
const textRows: string[][] = [];
|
||||
let inTable = false;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith('|') && trimmed.endsWith('|')) {
|
||||
// Skip separator rows
|
||||
if (trimmed.match(/^\|[\s-:|]+\|$/)) continue;
|
||||
inTable = true;
|
||||
const cells = trimmed
|
||||
.split('|')
|
||||
.slice(1, -1)
|
||||
.map((c) => c.trim());
|
||||
tableRows.push(cells);
|
||||
} else {
|
||||
if (inTable) inTable = false;
|
||||
if (trimmed) {
|
||||
textRows.push([trimmed]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create table sheet if tables found
|
||||
if (tableRows.length > 0) {
|
||||
const ws = XLSX.utils.aoa_to_sheet(tableRows);
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'Framework');
|
||||
}
|
||||
|
||||
// Create content sheet
|
||||
const title = toolName.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
const contentRows = [
|
||||
[`NGO Toolkit Lab — ${title}`],
|
||||
[''],
|
||||
...textRows,
|
||||
[''],
|
||||
['Generated by NGO Toolkit Lab'],
|
||||
];
|
||||
const contentWs = XLSX.utils.aoa_to_sheet(contentRows);
|
||||
XLSX.utils.book_append_sheet(wb, contentWs, 'Full Content');
|
||||
|
||||
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
|
||||
return Buffer.from(buf);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// Simple PDF generation using a text-to-PDF approach
|
||||
// @react-pdf/renderer requires React component rendering which is complex in API routes
|
||||
// Using a simpler approach with manual PDF construction
|
||||
|
||||
export async function generatePdf(
|
||||
content: string,
|
||||
toolName: string
|
||||
): Promise<Buffer> {
|
||||
// Simple PDF using minimal PDF spec
|
||||
const title = toolName.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
const header = `NGO Toolkit Lab — ${title}`;
|
||||
|
||||
// Clean content for PDF
|
||||
const cleanContent = content
|
||||
.replace(/\*\*/g, '')
|
||||
.replace(/#{1,3}\s/g, '')
|
||||
.replace(/\|/g, ' | ');
|
||||
|
||||
const lines = cleanContent.split('\n').filter((l) => l.trim());
|
||||
|
||||
// Build minimal PDF
|
||||
const objects: string[] = [];
|
||||
let objectCount = 0;
|
||||
const offsets: number[] = [];
|
||||
|
||||
const addObject = (content: string): number => {
|
||||
objectCount++;
|
||||
offsets.push(0); // placeholder
|
||||
objects.push(content);
|
||||
return objectCount;
|
||||
};
|
||||
|
||||
// Catalog
|
||||
addObject('1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj');
|
||||
|
||||
// Pages
|
||||
addObject('2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj');
|
||||
|
||||
// Build page content
|
||||
let textContent = `BT\n/F1 16 Tf\n50 800 Td\n(${escPdf(header)}) Tj\n`;
|
||||
textContent += `/F1 10 Tf\n0 -30 Td\n`;
|
||||
|
||||
let y = 770;
|
||||
for (const line of lines) {
|
||||
if (y < 50) break; // Simple single-page for now
|
||||
const safeLine = escPdf(line.substring(0, 100));
|
||||
textContent += `0 -14 Td\n(${safeLine}) Tj\n`;
|
||||
y -= 14;
|
||||
}
|
||||
|
||||
// Footer
|
||||
textContent += `0 -30 Td\n/F1 8 Tf\n(Generated by NGO Toolkit Lab) Tj\n`;
|
||||
textContent += 'ET';
|
||||
|
||||
const streamBytes = Buffer.from(textContent, 'utf-8');
|
||||
|
||||
// Page content stream
|
||||
addObject(
|
||||
`4 0 obj\n<< /Length ${streamBytes.length} >>\nstream\n${textContent}\nendstream\nendobj`
|
||||
);
|
||||
|
||||
// Font
|
||||
addObject('5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj');
|
||||
|
||||
// Page
|
||||
// Replace object 3 — insert at position 2
|
||||
objects.splice(
|
||||
2,
|
||||
0,
|
||||
`3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>\nendobj`
|
||||
);
|
||||
offsets.splice(2, 0, 0);
|
||||
objectCount = objects.length;
|
||||
|
||||
// Build PDF
|
||||
let pdf = '%PDF-1.4\n';
|
||||
for (let i = 0; i < objects.length; i++) {
|
||||
offsets[i] = pdf.length;
|
||||
pdf += objects[i] + '\n';
|
||||
}
|
||||
|
||||
const xrefOffset = pdf.length;
|
||||
pdf += 'xref\n';
|
||||
pdf += `0 ${objectCount + 1}\n`;
|
||||
pdf += '0000000000 65535 f \n';
|
||||
for (let i = 0; i < objectCount; i++) {
|
||||
pdf += `${String(offsets[i]).padStart(10, '0')} 00000 n \n`;
|
||||
}
|
||||
pdf += 'trailer\n';
|
||||
pdf += `<< /Size ${objectCount + 1} /Root 1 0 R >>\n`;
|
||||
pdf += 'startxref\n';
|
||||
pdf += `${xrefOffset}\n`;
|
||||
pdf += '%%EOF';
|
||||
|
||||
return Buffer.from(pdf, 'utf-8');
|
||||
}
|
||||
|
||||
function escPdf(text: string): string {
|
||||
return text
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/\(/g, '\\(')
|
||||
.replace(/\)/g, '\\)');
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { createHmac } from 'crypto';
|
||||
|
||||
export function verifyWebhookSignature(
|
||||
payload: string,
|
||||
signature: string
|
||||
): boolean {
|
||||
const secret = process.env.LEMONSQUEEZY_WEBHOOK_SECRET || '';
|
||||
const hmac = createHmac('sha256', secret);
|
||||
const digest = hmac.update(payload).digest('hex');
|
||||
return signature === digest;
|
||||
}
|
||||
|
||||
export async function createCheckout(
|
||||
variantId: string,
|
||||
sessionId: string,
|
||||
email?: string
|
||||
): Promise<string> {
|
||||
const response = await fetch('https://api.lemonsqueezy.com/v1/checkouts', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${process.env.LEMONSQUEEZY_API_KEY}`,
|
||||
'Content-Type': 'application/vnd.api+json',
|
||||
'Accept': 'application/vnd.api+json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
type: 'checkouts',
|
||||
attributes: {
|
||||
checkout_data: {
|
||||
email: email || undefined,
|
||||
custom: { session_id: sessionId },
|
||||
},
|
||||
},
|
||||
relationships: {
|
||||
store: {
|
||||
data: { type: 'stores', id: process.env.LEMONSQUEEZY_STORE_ID },
|
||||
},
|
||||
variant: {
|
||||
data: { type: 'variants', id: variantId },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
return data.data.attributes.url;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { ToolConfig } from './types';
|
||||
|
||||
export const tools: ToolConfig[] = [
|
||||
{
|
||||
slug: 'logframe',
|
||||
name: 'Logframe Generator',
|
||||
description: 'Generate professional logical framework matrices for grant applications in minutes.',
|
||||
icon: '📊',
|
||||
fields: [
|
||||
{ name: 'project_title', label: 'Project Title', type: 'text', required: true, placeholder: 'e.g. Clean Water Access Programme' },
|
||||
{ name: 'organisation', label: 'Organisation', type: 'text', required: true, placeholder: 'e.g. WaterAid' },
|
||||
{ name: 'sector', label: 'Sector', type: 'select', required: true, options: ['Health', 'Education', 'WASH', 'Livelihoods', 'Protection', 'Food Security', 'Climate', 'Governance', 'Other'] },
|
||||
{ name: 'beneficiaries', label: 'Target Beneficiaries', type: 'textarea', required: true, placeholder: 'Describe who benefits and estimated numbers' },
|
||||
{ name: 'location', label: 'Geographic Location', type: 'text', required: true, placeholder: 'e.g. Rural Kenya, Kakamega County' },
|
||||
{ name: 'duration', label: 'Project Duration', type: 'text', required: true, placeholder: 'e.g. 24 months' },
|
||||
{ name: 'budget', label: 'Budget Range', type: 'text', placeholder: 'e.g. £500,000 - £750,000' },
|
||||
{ name: 'summary', label: 'Project Summary', type: 'textarea', required: true, placeholder: 'Describe the project in 2-3 paragraphs' },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'funder-rewriter',
|
||||
name: 'Funder Rewriter',
|
||||
description: 'Tailor your proposal language to match specific funders\' priorities and terminology.',
|
||||
icon: '✍️',
|
||||
fields: [
|
||||
{ name: 'original_text', label: 'Original Proposal Text', type: 'textarea', required: true, placeholder: 'Paste your existing proposal text here' },
|
||||
{ name: 'funder_name', label: 'Target Funder', type: 'text', required: true, placeholder: 'e.g. DFID, USAID, Gates Foundation' },
|
||||
{ name: 'funder_type', label: 'Funder Type', type: 'select', required: true, options: ['Bilateral (Government)', 'Multilateral (UN/World Bank)', 'Foundation', 'Corporate', 'Trust', 'Other'] },
|
||||
{ name: 'funder_priorities', label: 'Funder Priorities', type: 'textarea', required: true, placeholder: 'List the funder\'s key strategic priorities' },
|
||||
{ name: 'grant_size', label: 'Grant Size', type: 'text', placeholder: 'e.g. £100,000 - £500,000' },
|
||||
{ name: 'sector', label: 'Sector', type: 'text', required: true, placeholder: 'e.g. Health, Education' },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'smart-indicators',
|
||||
name: 'SMART Indicators',
|
||||
description: 'Design comprehensive SMART indicator frameworks for M&E systems.',
|
||||
icon: '🎯',
|
||||
fields: [
|
||||
{ name: 'project_title', label: 'Project/Programme Name', type: 'text', required: true, placeholder: 'e.g. Maternal Health Improvement Programme' },
|
||||
{ name: 'sector', label: 'Sector', type: 'select', required: true, options: ['Health', 'Education', 'WASH', 'Livelihoods', 'Protection', 'Food Security', 'Climate', 'Governance', 'Other'] },
|
||||
{ name: 'outcomes', label: 'Outcomes to Measure', type: 'textarea', required: true, placeholder: 'List the key outcomes you need indicators for' },
|
||||
{ name: 'target_population', label: 'Target Population', type: 'textarea', required: true, placeholder: 'Who are you measuring change for?' },
|
||||
{ name: 'timeframe', label: 'Timeframe', type: 'text', required: true, placeholder: 'e.g. 3 years' },
|
||||
{ name: 'data_capacity', label: 'Data Collection Capacity', type: 'select', required: true, options: ['Basic (paper-based)', 'Moderate (some digital)', 'Advanced (full digital M&E system)'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'theory-of-change',
|
||||
name: 'Theory of Change',
|
||||
description: 'Build compelling theories of change with clear causal pathways and evidence.',
|
||||
icon: '🔄',
|
||||
fields: [
|
||||
{ name: 'project_title', label: 'Project/Organisation Name', type: 'text', required: true, placeholder: 'e.g. Youth Empowerment Initiative' },
|
||||
{ name: 'mission', label: 'Mission Statement', type: 'textarea', required: true, placeholder: 'What is your organisation/project mission?' },
|
||||
{ name: 'problem', label: 'Problem Statement', type: 'textarea', required: true, placeholder: 'What problem are you addressing? Include root causes.' },
|
||||
{ name: 'target_population', label: 'Target Population', type: 'textarea', required: true, placeholder: 'Who are you trying to help?' },
|
||||
{ name: 'context', label: 'Geographic/Social Context', type: 'textarea', required: true, placeholder: 'Describe the context in which you operate' },
|
||||
{ name: 'timeframe', label: 'Timeframe', type: 'text', required: true, placeholder: 'e.g. 5 years' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function getToolBySlug(slug: string): ToolConfig | undefined {
|
||||
return tools.find((t) => t.slug === slug);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
export type ToolName = 'logframe' | 'funder-rewriter' | 'smart-indicators' | 'theory-of-change';
|
||||
|
||||
export interface Session {
|
||||
id: string;
|
||||
tool: ToolName;
|
||||
email: string | null;
|
||||
input: Record<string, string>;
|
||||
output: string | null;
|
||||
is_paid: boolean;
|
||||
payment_id: string | null;
|
||||
improvements_used: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Lead {
|
||||
id: number;
|
||||
email: string;
|
||||
org_name: string | null;
|
||||
tool: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AdminConfig {
|
||||
price_amount: string;
|
||||
price_currency: string;
|
||||
free_tier_enabled: string;
|
||||
max_improvements: string;
|
||||
openai_model: string;
|
||||
lemonsqueezy_variant_id: string;
|
||||
}
|
||||
|
||||
export interface ToolConfig {
|
||||
slug: ToolName;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
fields: FormField[];
|
||||
}
|
||||
|
||||
export interface FormField {
|
||||
name: string;
|
||||
label: string;
|
||||
type: 'text' | 'textarea' | 'select';
|
||||
placeholder?: string;
|
||||
options?: string[];
|
||||
required?: boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user