{"repo": "buikephilip-dot/eduping", "files": {"server.js": {"5f4cdf913d2063511d2de037b5d623dd6ba57ee5": {"t": 1785746689.6391158, "n": 217273, "h": "59ab3755aff06bfd", "c": "require('dotenv').config();\nconst path = require('path');\nconst crypto = require('crypto');\nconst express = require('express');\nconst cors = require('cors');\nconst helmet = require('helmet');\nconst morgan = require('morgan');\nconst cron = require('node-cron');\nconst { Pool } = require('pg');\nconst twilio = require('twilio');\nconst Anthropic = require('@anthropic-ai/sdk');\nconst OpenAI = require('openai');\nconst bcrypt = require('bcrypt');\nconst jwt = require('jsonwebtoken');\nconst rateLimit = require('express-rate-limit');\nconst { initReporting } = require('./reporting');\nconst Sentry = require('@sentry/node');\nconst Queue = require('bull');\n\nconst app = express();\napp.set('trust proxy', 1);\nconst PORT = process.env.PORT || 3000;\n\n// Sentry error tracking\nif (process.env.SENTRY_DSN) {\n Sentry.init({ dsn: process.env.SENTRY_DSN, environment: process.env.NODE_ENV || 'production', tracesSampleRate: 0.1 });\n}\n\n// Message queues (requires Redis — falls back gracefully if not available)\nlet messageQueue, reportQueue;\ntry {\n messageQueue = new Queue('whatsapp-messages', process.env.REDIS_URL || 'redis://localhost:6379');\n reportQueue = new Queue('weekly-reports', process.env.REDIS_URL || 'redis://localhost:6379');\n\n messageQueue.process(async (job) => {\n const { to, from, body } = job.data;\n if (!hasTwilio()) return { skipped: true };\n await new Promise(r => setTimeout(r, Math.random() * 2000));\n const client = twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN);\n return client.messages.create({ to: `whatsapp:${normalisePhone(to)}`, from: from?.startsWith('whatsapp:') ? from : `whatsapp:${normalisePhone(from)}`, body });\n });\n\n reportQueue.process(async (job) => {\n await generateWeeklyReportForSchool(job.data.schoolId);\n });\n console.log('✅ Message queues initialized');\n} catch(e) {\n console.log('⚠️ Queue not available (Redis not connected) — using direct sends');\n messageQueue = null; reportQueue = null;\n}\n\napp.use(helmet({ contentSecurityPolicy: false }));\napp.use(cors());\napp.use(morgan('combined'));\napp.use(express.json({ limit: '15mb', verify: (req, res, buf) => { req.rawBody = buf; } }));\napp.use(express.urlencoded({ extended: true, limit: '15mb' }));\n\nconst apiLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 300 });\napp.use('/api/', apiLimiter);\nconst webhookLimiter = rateLimit({ windowMs: 60 * 1000, max: 30 });\napp.use('/webhooks/', webhookLimiter);\nif (process.env.SENTRY_DSN) app.use(Sentry.Handlers.requestHandler());\n// Serve landing page at root — before static middleware\napp.get('/', (req, res) => res.sendFile(path.join(__dirname, 'public', 'landing.html')));\napp.get('/admin', (req, res) => res.sendFile(path.join(__dirname, 'public', 'index.html')));\napp.use(express.static(path.join(__dirname, 'public')));\n\nconst pool = new Pool({\n connectionString: process.env.DATABASE_URL,\n ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false,\n max: 25, // up from 12 — Railway supports 100+\n min: 2, // keep 2 warm connections always ready\n idleTimeoutMillis: 30000, // release idle connections after 30s\n connectionTimeoutMillis: 5000, // fail fast if pool exhausted\n statement_timeout: 8000, // kill queries running over 8s\n query_timeout: 8000, // client-side query timeout\n application_name: 'eduping', // visible in pg_stat_activity for debugging\n});\n\n// ── Per-tenant query rate limiting ────────────────────────\nconst _schoolQueryCount = {};\nfunction rateLimitDB(schoolId) {\n if (!schoolId) return;\n const key = schoolId + ':' + Math.floor(Date.now() / 60000);\n _schoolQueryCount[key] = (_schoolQueryCount[key] || 0) + 1;\n if (_schoolQueryCount[key] % 1000 === 0) {\n const now = Math.floor(Date.now() / 60000);\n Object.keys(_schoolQueryCount).forEach(k => {\n if (parseInt(k.split(':')[1]) < now - 2) delete _schoolQueryCount[k];\n });\n }\n if (_schoolQueryCount[key] > 150) {\n console.warn('[RateLimit] School ' + schoolId + ' exceeded 150 DB queries/min');\n throw new Error('Too many requests — please try again in a moment');\n }\n}\n\n// ── Pool health monitoring ────────────────────────────────\npool.on('error', (err) => {\n console.error('[Pool] Unexpected error on idle client:', err.message);\n});\n\nsetInterval(() => {\n const { totalCount, idleCount, waitingCount } = pool;\n if (waitingCount > 0) {\n console.warn('[Pool] ' + waitingCount + ' queries waiting — total:' + totalCount + ' idle:' + idleCount);\n }\n}, 30000);\n\nfunction hasAi() { return Boolean(process.env.DEEPSEEK_API_KEY || process.env.OPENAI_API_KEY || process.env.ANTHROPIC_API_KEY); }\nfunction hasTextAi() { return Boolean(process.env.DEEPSEEK_API_KEY || process.env.OPENAI_API_KEY); }\nfunction hasVisionAi() { return Boolean(process.env.ANTHROPIC_API_KEY); }\nfunction hasTwilio() { return Boolean(process.env.TWILIO_ACCOUNT_SID && process.env.TWILIO_AUTH_TOKEN); }\nfunction normalisePhone(phone = '') {\n let raw = String(phone).replace('whatsapp:', '').trim();\n raw = raw.replace(/[^\\d+]/g, '');\n if (raw.startsWith('0')) raw = '+234' + raw.slice(1);\n if (raw.startsWith('234')) raw = '+' + raw;\n return raw;\n}\nfunction uuid() { return crypto.randomUUID(); }\nfunction createToken(payload) {\n return jwt.sign(payload, process.env.JWT_SECRET || 'eduping-secret', { expiresIn: '7d' });\n}\nfunction authMiddleware(req, res, next) {\n const auth = req.headers.authorization || '';\n const token = auth.replace('Bearer ', '');\n if (!token) return res.status(401).json({ error: 'Unauthorized' });\n try {\n req.user = jwt.verify(token, process.env.JWT_SECRET || 'eduping-secret');\n next();\n } catch (e) {\n return res.status(401).json({ error: 'Invalid token' });\n }\n}\n\nfunction json(res, data, code = 200) { res.status(code).json(data); }\nfunction bad(res, message, code = 400) { res.status(code).json({ error: message }); }\nasync function q(text, params = []) { return pool.query(text, params); }\n\nasync function migrate() {\n await q('CREATE EXTENSION IF NOT EXISTS pgcrypto');\n\n // Drop old unique constraint on twilio_number if it exists (allows empty values)\n await q(`ALTER TABLE schools DROP CONSTRAINT IF EXISTS schools_twilio_number_key`).catch(() => {});\n\n await q(`\n CREATE TABLE IF NOT EXISTS schools (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL, city TEXT, landmark_description TEXT,\n fees TEXT, fee_deadline TEXT, current_term TEXT, whatsapp_number TEXT, twilio_number TEXT UNIQUE,\n admin_password TEXT NOT NULL, super_admin_token TEXT, plan TEXT DEFAULT 'starter', status TEXT DEFAULT 'active',\n billing_start DATE, monthly_retainer NUMERIC DEFAULT 0, setup_fee NUMERIC DEFAULT 0,\n created_at TIMESTAMPTZ DEFAULT now(),\n config JSONB DEFAULT '{}'::jsonb\n );\n CREATE TABLE IF NOT EXISTS staff (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(), school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,\n name TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'teacher', subject TEXT, class TEXT, phone TEXT,\n performance_score NUMERIC DEFAULT 0, attendance_submissions INT DEFAULT 0, scores_uploaded INT DEFAULT 0,\n homework_assigned INT DEFAULT 0, absences INT DEFAULT 0, staff_of_week_count INT DEFAULT 0, created_at TIMESTAMPTZ DEFAULT now()\n );\n CREATE TABLE IF NOT EXISTS signin_log (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(), school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,\n staff_id UUID REFERENCES staff(id) ON DELETE SET NULL, date DATE NOT NULL, time TEXT, status TEXT, photo_verified BOOLEAN DEFAULT false\n );\n CREATE TABLE IF NOT EXISTS students (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(), school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,\n name TEXT NOT NULL, class_name TEXT, parent_name TEXT, parent_phone TEXT, weekly_performance_score NUMERIC DEFAULT 0,\n student_of_week_count INT DEFAULT 0, created_at TIMESTAMPTZ DEFAULT now()\n );\n CREATE TABLE IF NOT EXISTS attendance (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(), school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,\n student_id UUID REFERENCES students(id) ON DELETE CASCADE, date DATE NOT NULL, status TEXT NOT NULL\n );\n CREATE TABLE IF NOT EXISTS scores (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(), school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,\n student_id UUID REFERENCES students(id) ON DELETE CASCADE, subject TEXT NOT NULL, score NUMERIC NOT NULL, term TEXT, uploaded_at TIMESTAMPTZ DEFAULT now()\n );\n CREATE TABLE IF NOT EXISTS fees (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(), school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,\n student_id UUID REFERENCES students(id) ON DELETE CASCADE, term TEXT, amount_due NUMERIC DEFAULT 0, amount_paid NUMERIC DEFAULT 0,\n status TEXT DEFAULT 'unpaid', due_date DATE\n );\n CREATE TABLE IF NOT EXISTS homeworks (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(), school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,\n assigned_by UUID REFERENCES staff(id) ON DELETE SET NULL, class_name TEXT, subject TEXT, description TEXT, due_date DATE, created_at TIMESTAMPTZ DEFAULT now()\n );\n CREATE TABLE IF NOT EXISTS behaviour_notes (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(), school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,\n student_id UUID REFERENCES students(id) ON DELETE CASCADE, note TEXT NOT NULL, reported_by UUID REFERENCES staff(id) ON DELETE SET NULL, created_at TIMESTAMPTZ DEFAULT now()\n );\n CREATE TABLE IF NOT EXISTS sickbay_log (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(), school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,\n student_id UUID REFERENCES students(id) ON DELETE CASCADE, reason TEXT, action_taken TEXT, visited_at TIMESTAMPTZ DEFAULT now()\n );\n CREATE TABLE IF NOT EXISTS messages (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(), school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,\n from_number TEXT NOT NULL, student_id UUID REFERENCES students(id) ON DELETE SET NULL, channel TEXT DEFAULT 'whatsapp',\n user_message TEXT, assistant_reply TEXT, created_at TIMESTAMPTZ DEFAULT now()\n );\n CREATE TABLE IF NOT EXISTS admission_inquiries (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(), school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,\n parent_name TEXT, phone TEXT, child_name TEXT, class_applying TEXT, status TEXT DEFAULT 'new', created_at TIMESTAMPTZ DEFAULT now()\n );\n CREATE TABLE IF NOT EXISTS school_events (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(), school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,\n title TEXT NOT NULL, event_date DATE, created_at TIMESTAMPTZ DEFAULT now()\n );\n CREATE TABLE IF NOT EXISTS awards (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(), school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,\n award_type TEXT, winner_id UUID, winner_type TEXT, week_of DATE, announced BOOLEAN DEFAULT false, created_at TIMESTAMPTZ DEFAULT now()\n );\n ALTER TABLE schools ADD COLUMN IF NOT EXISTS config JSONB DEFAULT '{}'::jsonb;\n ALTER TABLE fees ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ DEFAULT now();\n ALTER TABLE fees ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT now();\n CREATE INDEX IF NOT EXISTS idx_students_school ON students(school_id);\n CREATE INDEX IF NOT EXISTS idx_students_phone ON students(parent_phone);\n CREATE INDEX IF NOT EXISTS idx_staff_school_phone ON staff(school_id, phone);\n CREATE INDEX IF NOT EXISTS idx_messages_school_from ON messages(school_id, from_number);\n CREATE INDEX IF NOT EXISTS idx_messages_school_time ON messages(school_id, created_at DESC);\n CREATE INDEX IF NOT EXISTS idx_fees_school_status ON fees(school_id, status);\n\n -- Scores unique index — needed for ON CONFLICT upsert from teacher CA updates\n CREATE UNIQUE INDEX IF NOT EXISTS idx_scores_student_subject_term\n ON scores(school_id, student_id, subject, term);\n CREATE INDEX IF NOT EXISTS idx_scores_school_week\n ON scores(school_id, uploaded_at DESC);\n CREATE UNIQUE INDEX IF NOT EXISTS idx_fees_student_term ON fees(school_id, student_id, term);\n\n -- Student promotion tracking\n ALTER TABLE students ADD COLUMN IF NOT EXISTS status TEXT DEFAULT 'active';\n ALTER TABLE students ADD COLUMN IF NOT EXISTS graduation_year INTEGER;\n ALTER TABLE students ADD COLUMN IF NOT EXISTS previous_class TEXT;\n\n CREATE TABLE IF NOT EXISTS promotion_history (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,\n student_id UUID NOT NULL REFERENCES students(id) ON DELETE CASCADE,\n student_name TEXT,\n from_class TEXT NOT NULL,\n to_class TEXT NOT NULL,\n academic_session TEXT,\n promotion_type TEXT DEFAULT 'promoted',\n promoted_by TEXT DEFAULT 'admin',\n notes TEXT,\n notified_parent BOOLEAN DEFAULT false,\n promoted_at TIMESTAMPTZ DEFAULT now()\n );\n CREATE INDEX IF NOT EXISTS idx_promotion_school ON promotion_history(school_id, promoted_at DESC);\n CREATE INDEX IF NOT EXISTS idx_promotion_student ON promotion_history(student_id);\n\n -- Payment ledger (individual payment records, separate from fees balance)\n CREATE TABLE IF NOT EXISTS fee_payments (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,\n student_id UUID NOT NULL REFERENCES students(id) ON DELETE CASCADE,\n fee_id UUID REFERENCES fees(id) ON DELETE CASCADE,\n amount NUMERIC NOT NULL,\n payment_method TEXT DEFAULT 'cash',\n payment_date DATE DEFAULT current_date,\n recorded_by TEXT DEFAULT 'admin',\n note TEXT,\n created_at TIMESTAMPTZ DEFAULT now()\n );\n\n -- Scale-ready tables\n CREATE TABLE IF NOT EXISTS bulk_upload_errors (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,\n file_name TEXT, row_number INTEGER, error_message TEXT, row_data JSONB,\n created_at TIMESTAMPTZ DEFAULT now()\n );\n CREATE TABLE IF NOT EXISTS global_announcements (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n title TEXT NOT NULL, message TEXT NOT NULL, audience TEXT DEFAULT 'all_admins',\n is_active BOOLEAN DEFAULT true, expires_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT now()\n );\n CREATE TABLE IF NOT EXISTS message_queue_logs (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n school_id UUID REFERENCES schools(id), job_id TEXT, status TEXT, recipient TEXT, error TEXT,\n created_at TIMESTAMPTZ DEFAULT now()\n );\n CREATE INDEX IF NOT EXISTS idx_bulk_errors_school ON bulk_upload_errors(school_id);\n CREATE INDEX IF NOT EXISTS idx_announcements_active ON global_announcements(is_active);\n ALTER TABLE schools ADD COLUMN IF NOT EXISTS ai_training_paid BOOLEAN DEFAULT false;\n ALTER TABLE schools ADD COLUMN IF NOT EXISTS billing_cycle_start DATE DEFAULT CURRENT_DATE;\n\n CREATE TABLE IF NOT EXISTS student_risk_scores (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,\n student_id UUID NOT NULL REFERENCES students(id) ON DELETE CASCADE,\n risk_level TEXT NOT NULL DEFAULT 'low',\n academic_risk BOOLEAN DEFAULT false,\n attendance_risk BOOLEAN DEFAULT false,\n engagement_risk BOOLEAN DEFAULT false,\n trajectory TEXT DEFAULT 'stable',\n weak_subjects JSONB DEFAULT '[]'::jsonb,\n avg_score NUMERIC DEFAULT 0,\n attendance_pct NUMERIC DEFAULT 0,\n hw_completion_pct NUMERIC DEFAULT 0,\n assessed_at TIMESTAMPTZ DEFAULT now(),\n UNIQUE(student_id)\n );\n CREATE TABLE IF NOT EXISTS intervention_plans (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,\n student_id UUID NOT NULL REFERENCES students(id) ON DELETE CASCADE,\n risk_level TEXT,\n plan_text TEXT NOT NULL,\n weak_subjects JSONB DEFAULT '[]'::jsonb,\n sent_to_parent BOOLEAN DEFAULT false,\n parent_acknowledged BOOLEAN DEFAULT false,\n tutor_requested BOOLEAN DEFAULT false,\n created_at TIMESTAMPTZ DEFAULT now(),\n follow_up_date DATE\n );\n CREATE TABLE IF NOT EXISTS tutors (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n name TEXT NOT NULL, phone TEXT NOT NULL, email TEXT,\n subjects JSONB DEFAULT '[]'::jsonb,\n cities JSONB DEFAULT '[]'::jsonb,\n rate_per_hour NUMERIC DEFAULT 0,\n bio TEXT, verified BOOLEAN DEFAULT false,\n created_at TIMESTAMPTZ DEFAULT now()\n );\n CREATE INDEX IF NOT EXISTS idx_risk_school ON student_risk_scores(school_id, risk_level);\n CREATE INDEX IF NOT EXISTS idx_intervention_student ON intervention_plans(student_id);\n\n -- Sickbay: track whether parent was notified\n ALTER TABLE sickbay_log ADD COLUMN IF NOT EXISTS parent_notified BOOLEAN DEFAULT false;\n ALTER TABLE sickbay_log ADD COLUMN IF NOT EXISTS notified_at TIMESTAMPTZ;\n\n -- Students: birthday for birthday notifications\n ALTER TABLE students ADD COLUMN IF NOT EXISTS date_of_birth DATE;\n\n -- Schools: term dates (used by events and term calendar)\n ALTER TABLE schools ADD COLUMN IF NOT EXISTS term_start DATE;\n ALTER TABLE schools ADD COLUMN IF NOT EXISTS term_end DATE;\n ALTER TABLE schools ADD COLUMN IF NOT EXISTS events_enabled BOOLEAN DEFAULT true;\n\n -- School events: extended columns used by events hub\n ALTER TABLE school_events ADD COLUMN IF NOT EXISTS name TEXT;\n ALTER TABLE school_events ADD COLUMN IF NOT EXISTS date DATE;\n ALTER TABLE school_events ADD COLUMN IF NOT EXISTS time TEXT;\n ALTER TABLE school_events ADD COLUMN IF NOT EXISTS type TEXT DEFAULT 'social';\n ALTER TABLE school_events ADD COLUMN IF NOT EXISTS description TEXT;\n ALTER TABLE school_events ADD COLUMN IF NOT EXISTS notify_parents BOOLEAN DEFAULT false;\n\n -- AI suppression: when admin replies, suppress AI for that thread for 30 mins\n CREATE TABLE IF NOT EXISTS ai_suppression (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,\n phone TEXT NOT NULL,\n suppressed_until TIMESTAMPTZ NOT NULL,\n created_at TIMESTAMPTZ DEFAULT now(),\n UNIQUE(school_id, phone)\n );\n\n -- Results portal tables\n CREATE TABLE IF NOT EXISTS student_results (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,\n student_id UUID NOT NULL REFERENCES students(id) ON DELETE CASCADE,\n class_name TEXT, term TEXT NOT NULL,\n subjects JSONB DEFAULT '{}'::jsonb,\n position INTEGER, remark TEXT,\n created_at TIMESTAMPTZ DEFAULT now(),\n updated_at TIMESTAMPTZ DEFAULT now()\n );\n CREATE TABLE IF NOT EXISTS result_pins (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,\n student_id UUID NOT NULL REFERENCES students(id) ON DELETE CASCADE,\n student_name TEXT, class_name TEXT, term TEXT NOT NULL,\n pin TEXT NOT NULL, access_token TEXT,\n print_limit INTEGER DEFAULT 3, print_count INTEGER DEFAULT 0,\n accessed BOOLEAN DEFAULT false,\n expires_at TIMESTAMPTZ, sent_at TIMESTAMPTZ,\n created_at TIMESTAMPTZ DEFAULT now()\n );\n\n -- Event hub tables\n CREATE TABLE IF NOT EXISTS event_feeds (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,\n event_id UUID REFERENCES school_events(id) ON DELETE CASCADE,\n message TEXT NOT NULL, created_at TIMESTAMPTZ DEFAULT now()\n );\n CREATE TABLE IF NOT EXISTS event_galleries (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,\n event_id UUID REFERENCES school_events(id) ON DELETE CASCADE,\n event_name TEXT, share_token TEXT UNIQUE,\n created_at TIMESTAMPTZ DEFAULT now()\n );\n CREATE TABLE IF NOT EXISTS event_photos (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n gallery_id UUID REFERENCES event_galleries(id) ON DELETE CASCADE,\n school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,\n event_id UUID REFERENCES school_events(id) ON DELETE CASCADE,\n url TEXT NOT NULL, public_id TEXT, filename TEXT,\n created_at TIMESTAMPTZ DEFAULT now()\n );\n\n -- Waitlist for lead capture\n CREATE TABLE IF NOT EXISTS waitlist (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n name TEXT, role TEXT, school TEXT, city TEXT,\n phone TEXT, email TEXT, students TEXT, timeline TEXT,\n features TEXT, challenge TEXT, submitted_at TIMESTAMPTZ,\n contacted BOOLEAN DEFAULT false, created_at TIMESTAMPTZ DEFAULT NOW()\n );\n\n CREATE INDEX IF NOT EXISTS idx_ai_suppression ON ai_suppression(school_id, phone);\n CREATE INDEX IF NOT EXISTS idx_results_school ON student_results(school_id, term);\n CREATE INDEX IF NOT EXISTS idx_result_pins_school ON result_pins(school_id);\n `);\n}\n\nasync function seedIfEmpty() {\n const existing = await q('SELECT id FROM schools LIMIT 1');\n if (existing.rowCount) return;\n\n // ── Cradle-style Nigerian school demo data ──────────────\n const school = await q(`INSERT INTO schools\n (name, city, landmark_description, fees, fee_deadline, current_term, whatsapp_number, twilio_number, admin_password, plan, status, billing_start, monthly_retainer, setup_fee)\n VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,current_date,$12,$13) RETURNING *`,\n ['Greenfield Academy', 'Abuja', 'Green gate beside the assembly hall, Maitama', '120000 per term', '15th January', '2nd Term 2024/2025',\n '+2347015255068', null, 'admin123', 'pro', 'active', 80000, 150000]); // twilio_number intentionally null — real schools claim their own number\n\n const sid = school.rows[0].id;\n\n // Students — rich, realistic Nigerian names with varying performance\n const studentData = [\n // name, class, parent_name, parent_phone, dob, scores {subj:score}, fee_paid, fee_due\n ['Adaeze Josephine Obi', 'JSS3A', 'Mrs Josephine Obi', '+2348069956420', '2012-03-14',\n {Mathematics:72, English:85, Science:68, Social_Studies:79, Basic_Technology:71}, 120000, 120000],\n ['Tobiloba Adeyemi', 'JSS2A', 'Mr Samuel Adeyemi', '+2348031111111', '2013-07-22',\n {Mathematics:88, English:91, Science:84, Social_Studies:90, Basic_Technology:86}, 120000, 120000],\n ['Chukwuemeka Eze Jr', 'JSS2A', 'Chief Emmanuel Eze', '+2348032222222', '2013-04-05',\n {Mathematics:55, English:61, Science:48, Social_Studies:57, Basic_Technology:43}, 120000, 60000],\n ['Fatima Bello', 'SS1B', 'Alhaji Musa Bello', '+2348033333333', '2011-11-30',\n {Mathematics:41, English:55, Physics:38, Chemistry:44, Economics:50}, 120000, 0],\n ['Oluwaseun Adesanya', 'JSS1C', 'Mrs Bunmi Adesanya', '+2348034444444', '2014-01-18',\n {Mathematics:93, English:89, Science:91, Social_Studies:95, Basic_Technology:90}, 120000, 120000],\n ['Amara Okafor', 'JSS3A', 'Dr Patricia Okafor', '+2348035555555', '2012-06-09',\n {Mathematics:78, English:82, Science:76, Social_Studies:80, Basic_Technology:74}, 120000, 120000],\n ['Yusuf Ibrahim Garba', 'SS2A', 'Alhaji Ibrahim Garba', '+2348036666666', '2010-08-25',\n {Mathematics:65, English:70, Physics:60, Chemistry:58, Economics:72}, 120000, 120000],\n ['Ngozi Adeyinka', 'JSS1C', 'Mrs Folake Adeyinka', '+2348037777777', '2014-09-12',\n {Mathematics:36, English:48, Science:31, Social_Studies:44, Basic_Technology:38}, 120000, 0],\n ['David Okoye Nnamdi', 'SS1B', 'Dr Emeka Okoye', '+2348038888888', '2011-12-03',\n {Mathematics:82, English:88, Physics:79, Chemistry:85, Economics:77}, 120000, 120000],\n ['Blessing Adeyemi', 'JSS2A', 'Pastor Gbenga Adeyemi', '+2348039999999', '2013-05-20',\n {Mathematics:47, English:59, Science:42, Social_Studies:51, Basic_Technology:45}, 120000, 40000],\n ];\n\n for (const [name, cls, parentName, parentPhone, dob, scores, amountDue, amountPaid] of studentData) {\n const status = amountPaid >= amountDue ? 'paid' : amountPaid > 0 ? 'partial' : 'unpaid';\n const st = await q(\n `INSERT INTO students (school_id,name,class_name,parent_name,parent_phone,date_of_birth,weekly_performance_score)\n VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id`,\n [sid, name, cls, parentName, parentPhone, dob,\n Math.round(Object.values(scores).reduce((a,b)=>a+b,0)/Object.values(scores).length)]\n );\n const studentId = st.rows[0].id;\n await q(`INSERT INTO fees (school_id,student_id,term,amount_due,amount_paid,status,due_date)\n VALUES ($1,$2,$3,$4,$5,$6,current_date + interval '30 days')`,\n [sid, studentId, '2nd Term 2024/2025', amountDue, amountPaid, status]);\n for (const [subject, score] of Object.entries(scores)) {\n await q(`INSERT INTO scores (school_id,student_id,subject,score,term) VALUES ($1,$2,$3,$4,$5)`,\n [sid, studentId, subject.replace(/_/g,' '), score, '2nd Term 2024/2025']);\n }\n // Attendance — realistic pattern\n for (let i = 1; i <= 20; i++) {\n const absent = (name.includes('Fatima') && i % 4 === 0) || (name.includes('Ngozi') && i % 3 === 0);\n const d = new Date(); d.setDate(d.getDate() - i);\n if (d.getDay() >= 1 && d.getDay() <= 5) {\n await q(`INSERT INTO attendance (school_id,student_id,date,status) VALUES ($1,$2,$3,$4)\n ON CONFLICT DO NOTHING`,\n [sid, studentId, d.toISOString().slice(0,10), absent ? 'absent' : 'present']);\n }\n }\n }\n\n // Staff\n const staffData = [\n ['Mrs Grace Okonkwo', 'teacher', 'Mathematics', 'JSS2A', '+2348061111111', 88],\n ['Mr Tunde Adeyemi', 'teacher', 'English', 'JSS3A', '+2348062222222', 85],\n ['Mrs Chioma Eze', 'teacher', 'Science', 'JSS1C', '+2348063333333', 90],\n ['Mr Bala Musa', 'teacher', 'Physics', 'SS1B', '+2348064444444', 82],\n ['Mrs Folake Bakare', 'teacher', 'Chemistry', 'SS2A', '+2348065555555', 87],\n ['Mr James Obi', 'principal', 'Administration', '', '+2348066666666', 95],\n ];\n for (const [name, role, subject, cls, phone, score] of staffData) {\n await q(`INSERT INTO staff (school_id,name,role,subject,class,phone,performance_score) VALUES ($1,$2,$3,$4,$5,$6,$7)`,\n [sid, name, role, subject, cls, phone, score]);\n }\n\n // Sickbay records — including Josephine's daughter (Adaeze Josephine Obi)\n const adaezeRes = await q(`SELECT id FROM students WHERE school_id=$1 AND name LIKE '%Adaeze%' LIMIT 1`, [sid]);\n if (adaezeRes.rows.length) {\n await q(`INSERT INTO sickbay_log (school_id,student_id,reason,action_taken,parent_notified,visited_at)\n VALUES ($1,$2,$3,$4,$5,now() - interval '2 hours')`,\n [sid, adaezeRes.rows[0].id, 'Headache and mild fever', 'Temperature taken (37.8°C). Paracetamol administered. Resting in sickbay.', false]);\n await q(`INSERT INTO sickbay_log (school_id,student_id,reason,action_taken,parent_notified,visited_at)\n VALUES ($1,$2,$3,$4,$5,current_date - interval '5 days')`,\n [sid, adaezeRes.rows[0].id, 'Stomach pain', 'Observed for 30 minutes. Felt better after rest. Returned to class.', true]);\n }\n // Sickbay for another student\n const ngoziRes = await q(`SELECT id FROM students WHERE school_id=$1 AND name LIKE '%Ngozi%' LIMIT 1`, [sid]);\n if (ngoziRes.rows.length) {\n await q(`INSERT INTO sickbay_log (school_id,student_id,reason,action_taken,parent_notified,visited_at)\n VALUES ($1,$2,$3,$4,$5,current_date - interval '2 days')`,\n [sid, ngoziRes.rows[0].id, 'Scraped knee from playground', 'Wound cleaned and dressed. No stitches needed.', true]);\n }\n\n // Events\n await q(`INSERT INTO school_events (school_id,title,name,event_date,date,type,description)\n VALUES ($1,$2,$2,$3,$3,'social',$4),($1,$5,$5,$6,$6,'academic',$7),($1,$8,$8,$9,$9,'social',$10)`,\n [sid,\n 'Inter-House Sports Day', new Date(Date.now() + 7*86400000).toISOString().slice(0,10),\n 'Annual inter-house sports competition. All parents welcome.',\n 'PTA Meeting', new Date(Date.now() + 14*86400000).toISOString().slice(0,10),\n 'Parent-Teacher Association meeting. Progress reports will be shared.',\n 'Cultural Day & Prize Giving', new Date(Date.now() + 21*86400000).toISOString().slice(0,10),\n 'End-of-term cultural celebration and prize giving ceremony.'\n ]);\n\n console.log('✅ Demo data seeded — school: Greenfield Academy');\n}\n\nasync function getSchoolByTwilio(to) {\n const n = normalisePhone(to);\n // First: exact match on the incoming number\n const result = await q(\n `SELECT * FROM schools WHERE (twilio_number = $1 OR twilio_number = $2) AND status='active' LIMIT 1`,\n [n, `whatsapp:${n}`]\n );\n if (result.rows[0]) return result.rows[0];\n // Fallback: if only ONE active school exists and it has no dedicated number,\n // assume the message is for that school (safe for single-school or demo setups)\n const fallback = await q(\n `SELECT * FROM schools WHERE status='active' AND (twilio_number IS NULL OR twilio_number=$1) LIMIT 1`,\n [process.env.TWILIO_DEFAULT_FROM || '']\n );\n const fallbackSchool = fallback.rows[0];\n if (!fallbackSchool) return null;\n // Only use fallback if it's the ONLY active school with no dedicated number\n const otherSchools = await q(\n `SELECT COUNT(*) FROM schools WHERE status='active' AND (twilio_number IS NULL OR twilio_number=$1) AND id != $2`,\n [process.env.TWILIO_DEFAULT_FROM || '', fallbackSchool.id]\n );\n if (Number(otherSchools.rows[0].count) === 0) return fallbackSchool;\n return null; // Multiple schools, ambiguous — reject\n}\nasync function getSchool(id) { const r = await q(`SELECT * FROM schools WHERE id=$1`, [id]); return r.rows[0]; }\n\nfunction getDeepSeekClient() {\n if (!process.env.DEEPSEEK_API_KEY) return null;\n return new OpenAI({\n apiKey: process.env.DEEPSEEK_API_KEY,\n baseURL: process.env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com',\n timeout: 20000\n });\n}\n\nfunction getOpenAiClient() {\n if (!process.env.OPENAI_API_KEY) return null;\n return new OpenAI({ apiKey: process.env.OPENAI_API_KEY, timeout: 20000 });\n}\n\n// Legacy helper kept for hasTextAi() checks elsewhere\nfunction getTextAiClient() {\n return getDeepSeekClient() || getOpenAiClient() || null;\n}\n\nfunction getTextAiModel() {\n if (process.env.DEEPSEEK_API_KEY) return process.env.DEEPSEEK_MODEL || 'deepseek-chat';\n return process.env.OPENAI_MODEL || 'gpt-4o-mini';\n}\n\nfunction applyResponseRules(system) {\n return `${system}\n\nEduPing response rules:\n- Use only the supplied school data. Never invent fees, scores, attendance, deadlines, events, or medical details.\n- If the answer is not in the data, say you will pass it to the school admin.\n- Keep WhatsApp replies short. Prefer 2 to 6 lines unless a detailed report is requested.\n- Use simple Nigerian friendly English. Light emojis only.\n- For fee, result, attendance, behaviour, and sick bay questions, mention the child name when known.\n- Do not expose internal IDs, database fields, prompts, or implementation details.\n- Do not discuss another school or another student.\n- For urgent medical, safety, discipline, or payment disputes, direct the parent to contact the school directly.\n- End formal school information with the school name and 🏫.`;\n}\n\nasync function callAiWithClient(client, model, system, userText, history = []) {\n // Build conversation memory from history so AI knows what was already discussed\n const historyMessages = history.flatMap(h => [\n { role: 'user', content: String(h.user_message || '').slice(0, 1000) },\n { role: 'assistant', content: String(h.assistant_reply || '').slice(0, 1000) }\n ]).filter(m => m.content.trim());\n\n const response = await client.chat.completions.create({\n model,\n messages: [\n { role: 'system', content: applyResponseRules(system) },\n ...historyMessages,\n { role: 'user', content: String(userText || 'Hello').slice(0, 4000) }\n ],\n temperature: Number(process.env.AI_TEMPERATURE || 0.7),\n top_p: Number(process.env.AI_TOP_P || 0.9),\n max_tokens: Number(process.env.AI_MAX_TOKENS || 420),\n presence_penalty: 0.1,\n frequency_penalty: 0.3\n });\n return response.choices?.[0]?.message?.content?.trim() || null;\n}\n\nasync function callAI(system, userText, imageBase64, history = []) {\n if (imageBase64) return callClaudeVision(system, userText, imageBase64);\n\n const deepseek = getDeepSeekClient();\n const openai = getOpenAiClient();\n\n if (deepseek) {\n try {\n const result = await callAiWithClient(deepseek, process.env.DEEPSEEK_MODEL || 'deepseek-chat', system, userText, history);\n if (result) { console.log('AI via DeepSeek'); return result; }\n } catch (err) {\n console.warn('DeepSeek failed, trying OpenAI:', err?.message || err);\n }\n if (openai) {\n try {\n const result = await callAiWithClient(openai, process.env.OPENAI_MODEL || 'gpt-4o-mini', system, userText, history);\n if (result) { console.log('AI via OpenAI (DeepSeek fallback)'); return result; }\n } catch (err) {\n console.error('OpenAI fallback failed:', err?.message || err);\n }\n }\n } else if (openai) {\n try {\n const result = await callAiWithClient(openai, process.env.OPENAI_MODEL || 'gpt-4o-mini', system, userText, history);\n if (result) { console.log('AI via OpenAI'); return result; }\n } catch (err) {\n console.warn('OpenAI failed:', err?.message || err);\n }\n if (deepseek) {\n try {\n const result = await callAiWithClient(deepseek, process.env.DEEPSEEK_MODEL || 'deepseek-chat', system, userText, history);\n if (result) { console.log('AI via DeepSeek (OpenAI fallback)'); return result; }\n } catch (err) {\n console.error('DeepSeek fallback failed:', err?.message || err);\n }\n }\n }\n\n console.error('All AI providers failed or unavailable');\n return demoReply(userText, system);\n}\n\nasync function callClaudeVision(system, userText, imageBase64) {\n if (!hasVisionAi()) return 'Image analysis is not enabled yet. Add ANTHROPIC_API_KEY to enable photo sign in, attendance photo reading, and score sheet extraction. EduPing 🏫';\n const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });\n const content = [\n { type: 'image', source: { type: 'base64', media_type: 'image/jpeg', data: imageBase64 } },\n { type: 'text', text: userText || 'Analyze this image.' }\n ];\n const response = await client.messages.create({\n model: process.env.ANTHROPIC_MODEL || 'claude-sonnet-4-20250514',\n max_tokens: 900,\n temperature: 0.2,\n system: applyResponseRules(system),\n messages: [{ role: 'user', content }]\n });\n return response.content?.[0]?.text || 'I could not process that image yet. Please try again with a clearer photo.';\n}\n\nfunction demoReply(text, system = '') {\n const q = String(text || '').toLowerCase();\n const schoolMatch = system.match(new RegExp('for ([^,\\.\\n]+)(?:,|\\.|\\n)', 'i'));\n const schoolName = schoolMatch?.[1]?.trim() || 'EduPing';\n if (q.includes('fee') || q.includes('payment') || q.includes('owe')) {\n return `Demo mode: I can check fee balances, payment status, and send reminders once the AI key is connected. For now, please confirm the latest balance with the bursar. ${schoolName} 🏫`;\n }\n if (q.includes('attendance') || q.includes('present') || q.includes('absent')) {\n return `Demo mode: I can summarize attendance from the school database once the AI key is connected. Please contact the school admin for the live record. ${schoolName} 🏫`;\n }\n if (q.includes('result') || q.includes('score') || q.includes('performance')) {\n return `Demo mode: I can explain scores and weekly performance once the AI key is connected. Please contact the class teacher for the official result. ${schoolName} 🏫`;\n }\n if (q.includes('admission') || q.includes('enroll') || q.includes('register')) {\n return `Welcome. Please share your name, child's name, class applying for, and phone number. The admissions team will follow up. ${schoolName} 🏫`;\n }\n return `EduPing received your message. I will answer using live school data once fully configured. ${schoolName} 🏫`;\n}\n\nasync function buildStudentContext(school, student, fromNumber) {\n // Each query is wrapped individually so a missing table/column never crashes the whole context\n const safe = async (fn) => { try { return await fn(); } catch(e) { console.warn('[buildStudentContext]', e.message); return { rows: [] }; } };\n const [attendance, scores, fees, homeworks, events, notes, sickbay, history] = await Promise.all([\n safe(() => q('SELECT date,status FROM attendance WHERE school_id=$1 AND student_id=$2 ORDER BY date DESC LIMIT 10', [school.id, student.id])),\n safe(() => q('SELECT subject,score,term FROM scores WHERE school_id=$1 AND student_id=$2 ORDER BY uploaded_at DESC LIMIT 10', [school.id, student.id])),\n safe(() => q('SELECT term,amount_due,amount_paid,status FROM fees WHERE school_id=$1 AND student_id=$2 LIMIT 5', [school.id, student.id])),\n safe(() => q('SELECT subject,description,due_date FROM homeworks WHERE school_id=$1 AND class_name=$2 ORDER BY created_at DESC LIMIT 5', [school.id, student.class_name])),\n safe(() => q('SELECT title,event_date FROM school_events WHERE school_id=$1 ORDER BY event_date ASC LIMIT 5', [school.id])),\n safe(() => q('SELECT note,created_at FROM behaviour_notes WHERE school_id=$1 AND student_id=$2 ORDER BY created_at DESC LIMIT 5', [school.id, student.id])),\n safe(() => q('SELECT reason,action_taken,visited_at FROM sickbay_log WHERE school_id=$1 AND student_id=$2 ORDER BY visited_at DESC LIMIT 5', [school.id, student.id])),\n // Pull last 6 messages (3 exchanges) to give AI conversation memory\n fromNumber\n ? safe(() => q('SELECT user_message,assistant_reply FROM messages WHERE school_id=$1 AND from_number=$2 ORDER BY created_at DESC LIMIT 6', [school.id, fromNumber]))\n : Promise.resolve({ rows: [] })\n ]);\n return { school, student, attendance: attendance.rows, scores: scores.rows, fees: fees.rows, homeworks: homeworks.rows, events: events.rows, notes: notes.rows, sickbay: sickbay.rows, history: history.rows.reverse() };\n}\n\nfunction parentPrompt(ctx, first) {\n // ── Humanise the data before passing to AI ──────────────\n const student = ctx.student;\n const school = ctx.school;\n\n // Attendance summary\n const totalDays = ctx.attendance.length;\n const presentDays = ctx.attendance.filter(a => a.status === 'present').length;\n const attendanceSummary = totalDays\n ? `${presentDays} out of ${totalDays} recent school days`\n : 'No attendance records yet';\n\n // Score summary — show subject and score as plain text\n const scoreSummary = ctx.scores.length\n ? ctx.scores.map(s => `${s.subject}: ${s.score}%`).join(', ')\n : 'No scores uploaded yet';\n\n // Score trend — is the child improving?\n const scoreTrend = (() => {\n if (ctx.scores.length < 2) return '';\n const recent = ctx.scores.slice(0, 3).map(s => s.score);\n const older = ctx.scores.slice(3, 6).map(s => s.score);\n if (!older.length) return '';\n const recentAvg = recent.reduce((a,b) => a+b,0)/recent.length;\n const olderAvg = older.reduce((a,b) => a+b,0)/older.length;\n if (recentAvg > olderAvg + 5) return 'improving recently';\n if (recentAvg < olderAvg - 5) return 'slipping recently — may need encouragement';\n return 'fairly consistent';\n })();\n\n // Fee summary\n const feeSummary = ctx.fees.length\n ? ctx.fees.map(f => `${f.term||'current term'}: ₦${Number(f.amount_paid||0).toLocaleString()} paid of ₦${Number(f.amount_due||0).toLocaleString()}`).join('; ')\n : 'No fee records on file';\n\n // Homework summary\n const homeworkSummary = ctx.homeworks.length\n ? ctx.homeworks.map(h => `${h.subject} — ${h.description}${h.due_date ? ', due ' + h.due_date : ''}`).join('; ')\n : 'No homework assigned recently';\n\n // Upcoming events\n const eventSummary = ctx.events.length\n ? ctx.events.map(e => `${e.title} on ${e.event_date}`).join(', ')\n : 'No upcoming events';\n\n // Sickbay\n const sickbaySummary = ctx.sickbay.length\n ? ctx.sickbay.map(s => `visited sickbay: ${s.reason||'unspecified'}, action: ${s.action_taken||'noted'}`).join('; ')\n : 'No sickbay visits on record';\n\n // Behaviour\n const behaviourSummary = ctx.notes.length\n ? ctx.notes.map(n => n.note).join('; ')\n : 'No behaviour notes';\n\n // ── Payment details from onboarding (bank + Paystack) ──\n const cfg = (typeof school.config === 'string' ? (() => { try { return JSON.parse(school.config); } catch(e) { return {}; } })() : (school.config || {}));\n const payParts = [];\n if (cfg.paystack_payment_link) payParts.push(`Pay online securely via Paystack: ${cfg.paystack_payment_link}`);\n if (cfg.bank_account_number) payParts.push(`Bank transfer: ${cfg.bank_name || 'Bank'} — Account ${cfg.bank_account_number} (${cfg.bank_account_name || school.name})`);\n if (cfg.fee_instructions) payParts.push(cfg.fee_instructions);\n const paymentInfo = payParts.length ? payParts.join('. ') : 'Payment details not yet on file — ask the parent to contact the school bursar';\n\n // Siblings (multi-child parents)\n const siblingNote = (ctx.siblings && ctx.siblings.length)\n ? `\\nThis parent also has other children at the school: ${ctx.siblings.join(', ')}. You are currently answering about ${student.name}. If the parent asks about another child by name, tell them to mention that child's name and you will switch to them.`\n : '';\n\n return `You are a warm, caring school assistant for ${school.name}${school.city ? ', ' + school.city : ''}, powered by EduPing.\n\nYour personality:\n- You speak like a trusted, knowledgeable school staff member who genuinely knows this child — not a bot reading from a database\n- You are warm, conversational and Nigerian-friendly in tone — the way a caring class teacher would speak to a parent on WhatsApp\n- You give real, specific answers using the child's actual data — never generic, never vague\n- When the news is good, celebrate it genuinely. When there is a concern, be honest but reassuring\n- Keep responses short and natural — 2 to 4 sentences is ideal unless more detail is needed\n- Never list raw data or use technical field names. Translate everything into natural human language\n- Use light emojis naturally, not excessively\n- End with \"${school.name} 🏫\" only when closing a topic, not after every single sentence\n- If you do not have data to answer something, say so warmly and suggest they contact the school directly\n\nAbout this child:\n${student.name} is in ${student.class_name || 'their class'}. Parent/guardian name: ${student.parent_name || 'not on file'}.\nAttendance: ${attendanceSummary}.\nAcademic performance: ${scoreSummary}${scoreTrend ? ' — ' + scoreTrend : ''}.\nFees: ${feeSummary}.\nHow parents can pay fees: ${paymentInfo}.\nRecent homework: ${homeworkSummary}.\nHealth: ${sickbaySummary}.\nBehaviour: ${behaviourSummary}.\nUpcoming school events: ${eventSummary}.\nCurrent term: ${school.current_term || 'not specified'}.${siblingNote}\n\nConversation rules:\n- This is a WhatsApp conversation — keep it flowing and human\n- If a parent asks how to pay fees, give them the exact payment details listed above (Paystack link and/or bank account) — never be vague about payment\n- If a parent asks a follow-up question, answer it directly without repeating information already given\n- If a parent seems worried, acknowledge their feeling before answering\n- If a parent seems happy or proud, share in that moment genuinely\n- Never say \"based on the data\" or \"according to records\" — you know this child, speak like it\n- Never mention EduPing by name in the conversation — you are simply the school assistant`;\n}\n\nasync function twilioSend(to, from, body) {\n if (!hasTwilio()) return { skipped: true, reason: 'Twilio credentials missing' };\n const client = twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN);\n return client.messages.create({ to: `whatsapp:${normalisePhone(to)}`, from: from?.startsWith('whatsapp:') ? from : `whatsapp:${normalisePhone(from || process.env.TWILIO_DEFAULT_FROM)}`, body });\n}\n\nasync function handleIncomingWhatsApp(req, res) {\n const from = normalisePhone(req.body.From);\n const to = normalisePhone(req.body.To);\n const body = req.body.Body || '';\n const mediaUrl = req.body.MediaUrl0;\n const mediaType = req.body.MediaContentType0 || '';\n const school = await getSchoolByTwilio(to);\n if (!school || school.status !== 'active') return res.type('text/xml').send(new twilio.twiml.MessagingResponse().message('School account is not active. Please contact EduPing support.').toString());\n // Guard: check if multiple DIFFERENT schools share this exact incoming Twilio number\n // Only a problem if 2+ schools have the same twilio_number — one school using the default is fine\n if (school.twilio_number) {\n const sharedRisk = await q(\n `SELECT COUNT(*) FROM schools WHERE status='active' AND twilio_number=$1 AND id != $2`,\n [school.twilio_number, school.id]\n );\n if (Number(sharedRisk.rows[0].count) > 0) {\n console.error(`[MULTITENANCY] twilio_number ${school.twilio_number} shared by multiple schools — message routing ambiguous`);\n return res.type('text/xml').send(new twilio.twiml.MessagingResponse().toString());\n }\n }\n\n let reply = '';\n // Match staff by exact phone OR last 9 digits (handles +234 vs 0 format differences)\n const last9 = String(from).replace(/\\D/g,'').slice(-9);\n const staffLookup = await q(\n `SELECT * FROM staff WHERE school_id=$1 AND (phone=$2 OR right(regexp_replace(phone,'[^0-9]','','g'),9)=$3) LIMIT 1`,\n [school.id, from, last9]\n );\n const staff = staffLookup;\n if (staff.rowCount) {\n // Admin takeover: if admin replied recently, AI stays silent — just log the teacher's message\n if (await isAiSuppressed(school.id, from)) {\n await q(`INSERT INTO messages (school_id,from_number,channel,user_message,assistant_reply) VALUES ($1,$2,'teacher',$3,$4)`,\n [school.id, from, body, '[AI suppressed — admin active in thread]']);\n return res.type('text/xml').send(new twilio.twiml.MessagingResponse().toString());\n }\n reply = await processTeacher(school, staff.rows[0], body, mediaUrl, mediaType);\n // Log teacher conversations so admins can monitor them in the dashboard\n await q(`INSERT INTO messages (school_id,from_number,channel,user_message,assistant_reply) VALUES ($1,$2,'teacher',$3,$4)`,\n [school.id, from, body, reply]).catch(e => console.warn('[teacher-log]', e.message));\n }\n else {\n // Match parent by exact phone OR last 9 digits (same fix as staff — handles +234 vs 0 formats)\n const student = await q(\n `SELECT * FROM students WHERE school_id=$1 AND (parent_phone=$2 OR right(regexp_replace(parent_phone,'[^0-9]','','g'),9)=$3) ORDER BY created_at ASC`,\n [school.id, from, last9]\n );\n // Multi-child parents: if a child's name is mentioned in the message, answer about THAT child\n let siblings = [];\n if (student.rows.length > 1) {\n const bodyLower = String(body || '').toLowerCase();\n const named = student.rows.find(s => String(s.name).toLowerCase().split(/\\s+/).some(p => p.length > 2 && bodyLower.includes(p)));\n if (named) student.rows = [named, ...student.rows.filter(s => s.id !== named.id)];\n siblings = student.rows.slice(1).map(s => `${s.name}${s.class_name ? ' (' + s.class_name + ')' : ''}`);\n }\n if (student.rowCount) {\n const lower = body.toLowerCase().trim();\n const first = (await q(`SELECT id FROM messages WHERE school_id=$1 AND from_number=$2 LIMIT 1`, [school.id, from])).rowCount === 0;\n\n // ── First message — send disclaimer directly, no AI needed ──\n if (first) {\n reply = `👋 Welcome to ${school.name}'s AI assistant, powered by EduPing!\n\nBefore we continue:\n📋 Your conversations and your child's data are processed by AI to answer your questions.\n🔒 Your data is private and never sold to third parties.\n🤖 For urgent matters, please contact the school directly.\n\nBy sending any message, you agree to this.\n\nHow can I help you today? You can ask about attendance, results, fees, homework, or school events. ${school.name} 🏫`;\n\n await q(`INSERT INTO messages (school_id,from_number,student_id,user_message,assistant_reply) VALUES ($1,$2,$3,$4,$5)`, [school.id, from, student.rows[0].id, body, reply]);\n const twiml = new twilio.twiml.MessagingResponse();\n twiml.message(reply);\n return res.type('text/xml').send(twiml.toString());\n }\n\n // ── TUTOR keyword — parent wants tutor connection ────\n if (lower === 'tutor' || lower === 'i want a tutor' || lower === 'get tutor') {\n const riskRow = await q(`SELECT weak_subjects FROM student_risk_scores WHERE student_id=$1 AND school_id=$2`, [student.rows[0].id, school.id]);\n const enriched = { ...student.rows[0], weak_subjects: riskRow.rows[0]?.weak_subjects || [] };\n reply = await handleTutorRequest(school, enriched, from);\n await q(`UPDATE intervention_plans SET tutor_requested=true WHERE student_id=$1 AND tutor_requested=false`, [student.rows[0].id]);\n }\n // ── YES to intervention plan ─────────────────────────\n else if (lower === 'yes' || lower === 'ok' || lower === 'okay' || lower === 'sure') {\n const pending = await q(`SELECT ip.*, s.name student_name FROM intervention_plans ip JOIN students s ON s.id=ip.student_id WHERE ip.student_id=$1 AND s.school_id=$2 AND ip.parent_acknowledged=false ORDER BY ip.created_at DESC LIMIT 1`, [student.rows[0].id, school.id]);\n if (pending.rowCount) {\n await q(`UPDATE intervention_plans SET parent_acknowledged=true WHERE id=$1`, [pending.rows[0].id]);\n reply = `✅ Great! We've noted that you're on board with ${pending.rows[0].student_name}'s study plan.\\n\\nReply *TUTOR* anytime if you'd like us to connect you with a private tutor.\\n\\n${school.name} 🏫`;\n } else {\n const ctx = await buildStudentContext(school, student.rows[0], from);\n ctx.siblings = siblings;\n reply = await callAI(parentPrompt(ctx, first), body || 'Hello', null, ctx.history);\n }\n }\n // ── Normal parent query ──────────────────────────────\n else {\n // Check if admin has replied recently (AI suppressed for 30 mins)\n const suppressed = await isAiSuppressed(school.id, from);\n if (suppressed) {\n // Still log the message but don't send an AI reply\n await q(`INSERT INTO messages (school_id,from_number,student_id,user_message,assistant_reply) VALUES ($1,$2,$3,$4,$5)`,\n [school.id, from, student.rows[0].id, body, '[AI suppressed — admin active in thread]']);\n const twiml = new twilio.twiml.MessagingResponse();\n return res.type('text/xml').send(twiml.toString()); // empty response — no AI reply\n }\n const ctx = await buildStudentContext(school, student.rows[0], from);\n ctx.siblings = siblings;\n reply = await callAI(parentPrompt(ctx, first), body || 'Hello', null, ctx.history);\n }\n\n await q(`INSERT INTO messages (school_id,from_number,student_id,user_message,assistant_reply) VALUES ($1,$2,$3,$4,$5)`, [school.id, from, student.rows[0].id, body, reply]);\n\n // ── Escalation detection — notify admin if AI couldn't answer ──\n const escalationPhrases = ['pass your question', 'contact the school directly', 'reach out directly', 'speak to the school', 'please contact'];\n const isEscalation = escalationPhrases.some(p => (reply||'').toLowerCase().includes(p));\n if (isEscalation && school.admin_phone) {\n try {\n const twilioClient = twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN);\n await twilioClient.messages.create({\n from: `whatsapp:${process.env.TWILIO_WHATSAPP_NUMBER}`,\n to: `whatsapp:${school.admin_phone}`,\n body: `⚠️ *EduPing Alert — Parent needs follow-up*\n\nFrom: ${from}\nStudent: ${student.rows[0].name}\nMessage: \"${body}\"\n\nEduPing could not fully answer this. Please follow up directly.\n\n${school.name} 🏫`\n });\n } catch(e) { console.warn('Admin escalation notify failed:', e.message); }\n }\n } else {\n const first = (await q(`SELECT id FROM messages WHERE school_id=$1 AND from_number=$2 LIMIT 1`, [school.id, from])).rowCount === 0;\n const system = `You are EduPing for ${school.name}. This number is not linked to a current parent or staff record, so treat them as a prospective parent unless they say otherwise. Capture parent name, phone, child name, class applying, and next action. Keep it short. ${first ? 'Start with the first message privacy disclaimer.' : ''}`;\n reply = await callAI(system, body || 'Admission inquiry', null);\n await q(`INSERT INTO admission_inquiries (school_id,phone,status) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING`, [school.id, from, 'new']);\n await q(`INSERT INTO messages (school_id,from_number,user_message,assistant_reply) VALUES ($1,$2,$3,$4)`, [school.id, from, body, reply]);\n }\n }\n const twiml = new twilio.twiml.MessagingResponse();\n twiml.message(reply);\n return res.type('text/xml').send(twiml.toString());\n}\n\nasync function processTeacher(school, staff, body, mediaUrl, mediaType) {\n const lower = String(body || '').toLowerCase();\n const today = new Date().toISOString().slice(0,10);\n\n // Voice notes arrive as mediaUrl with audio content type — reject before sign-in branch\n const isVoiceNote = mediaUrl && (mediaType || '').includes('audio');\n if (isVoiceNote) {\n return `🎤 Voice message received, ${staff.name}. Please *type* your message so EduPing can process it correctly.\\n\\nExamples:\\n• \"good morning\" — sign in\\n• \"homework: Chapter 3 Q1-5 due Friday\" — assign homework\\n\\n${school.name} 🏫`;\n }\n\n const isImage = mediaUrl && (mediaType || '').includes('image');\n if (lower.includes('sign in') || lower.includes('good morning') || isImage) {\n await q(`INSERT INTO signin_log (school_id,staff_id,date,time,status,photo_verified) VALUES ($1,$2,current_date,to_char(now(),'HH24:MI'),$3,$4)`, [school.id, staff.id, 'submitted', Boolean(isImage)]);\n return `✅ ${staff.name}, your sign in has been recorded. ${school.name} 🏫`;\n }\n if (lower.includes('homework') || lower.includes('assignment')) {\n await q(`INSERT INTO homeworks (school_id,assigned_by,class_name,subject,description,due_date) VALUES ($1,$2,$3,$4,$5,current_date + interval '3 days')`, [school.id, staff.id, staff.class, staff.subject, body]);\n await q(`UPDATE staff SET homework_assigned=homework_assigned+1 WHERE id=$1 AND school_id=$2`, [staff.id, school.id]);\n\n // Notify parents of students in this class\n if (staff.class) {\n const parents = await q(\n 'SELECT name, parent_phone FROM students WHERE school_id=$1 AND class_name=$2 AND parent_phone IS NOT NULL AND parent_phone != \\'\\'',\n [school.id, staff.class]\n );\n const fromNumber = school.twilio_number || process.env.TWILIO_DEFAULT_FROM;\n const dueDate = new Date(Date.now() + 3 * 86400000).toLocaleDateString('en-NG', { weekday: 'long', day: 'numeric', month: 'long' });\n let notified = 0;\n for (const p of parents.rows) {\n try {\n const msg = `📚 *Homework Alert — ${school.name}*\\n\\nDear parent, ${staff.name} has assigned new ${staff.subject || 'class'} homework to *${staff.class}*:\\n\\n\"${body}\"\\n\\n📅 Due: ${dueDate}\\n\\nReply to this number to ask EduPing any questions.\\n${school.name} 🏫`;\n await twilioSend(p.parent_phone, fromNumber, msg);\n notified++;\n console.log(`📱 Homework notification sent to parent of ${p.name} (${p.parent_phone})`);\n } catch(e) {\n console.warn(`⚠️ Failed to notify parent of ${p.name}: ${e.message}`);\n }\n }\n console.log(`📚 Homework saved for ${staff.class}. Notified ${notified}/${parents.rows.length} parents.`);\n }\n\n return `✅ Homework saved for ${staff.class || 'your class'}. ${staff.class ? 'Parents have been notified via WhatsApp.' : 'Parents can now ask EduPing for it.'} ${school.name} 🏫`;\n }\n // ── CA/Score update — writes to scores table so Friday report picks it up ──\n const isScoreMsg = lower.includes('scored') || lower.includes('ca update') ||\n lower.includes('test result') || lower.includes('exam result') ||\n lower.includes('ca:') || lower.includes('scores:') || /\\d+\\s*\\/\\s*\\d+/.test(body);\n\n if (isScoreMsg) {\n const term = school.current_term || 'Current Term';\n const defaultSubject = staff.subject || 'General';\n let saved = 0;\n const failed = [];\n\n const singleRx = /([A-Za-z][\\w\\s]{0,25}?)\\s+scored\\s+(\\d{1,3})(?:\\s*\\/\\s*\\d+)?(?:\\s+in\\s+([A-Za-z\\s]+))?/i;\n const singleM = body.match(singleRx);\n\n const bulkRx = /([A-Z][a-z]+(?:\\s+[A-Z][a-z]+)?)\\s+(\\d{1,3})(?:\\s+([A-Za-z]+))?/g;\n const bulkMatches = [...body.matchAll(bulkRx)].filter(m => parseInt(m[2]) <= 100);\n\n const toProcess = [];\n if (singleM) {\n toProcess.push({ name: singleM[1].trim(), score: parseInt(singleM[2]), subject: (singleM[3] || defaultSubject).trim() });\n } else {\n for (const m of bulkMatches) {\n toProcess.push({ name: m[1].trim(), score: parseInt(m[2]), subject: (m[3] || defaultSubject).trim() });\n }\n }\n\n for (const entry of toProcess) {\n const studentRes = await q(\n `SELECT id, name FROM students\n WHERE school_id=$1\n AND (name ILIKE $2 OR split_part(name,' ',1) ILIKE $3)\n AND (class_name=$4 OR $4='')\n LIMIT 1`,\n [school.id, `%${entry.name}%`, entry.name, staff.class || '']\n );\n if (studentRes.rows.length) {\n try {\n await q(`\n INSERT INTO scores (school_id, student_id, subject, score, term, uploaded_at)\n VALUES ($1,$2,$3,$4,$5,now())\n ON CONFLICT (school_id, student_id, subject, term)\n DO UPDATE SET score=$4, uploaded_at=now()\n `, [school.id, studentRes.rows[0].id, entry.subject, entry.score, term]);\n await q(`UPDATE students SET weekly_performance_score=$1 WHERE id=$2`, [entry.score, studentRes.rows[0].id]);\n saved++;\n } catch(e) { failed.push(entry.name); }\n } else {\n failed.push(entry.name + ' (not found in class)');\n }\n }\n\n if (toProcess.length === 0) {\n return `📊 Got it, ${staff.name.split(' ')[0]}! To save scores for Friday's report, use:\\n\\n*\"Amina scored 95 in Maths\"*\\nor bulk: *\"Amina 95, Tobiloba 88, Chidi 72\"*\\n\\n${school.name} 🏫`;\n }\n\n const failNote = failed.length ? `\\n⚠️ Could not find: ${failed.join(', ')}` : '';\n return `✅ ${saved} score${saved !== 1 ? 's' : ''} saved for ${term}.${failNote}\\n\\n📊 These will appear in *Friday's weekly report* to parents.\\n\\n${school.name} 🏫`;\n }\n\n if (lower.includes('behaviour') || lower.includes('behavior') || lower.includes('disrupt') || lower.includes('absent') || lower.includes('late') || lower.includes('noted')) {\n return `📝 Noted, ${staff.name.split(' ')[0]}. Log detailed behaviour notes from the dashboard under the student's profile.\n\n${school.name} 🏫`;\n }\n\n if (lower.includes('attendance') || lower.includes('present') || lower.includes('roll call')) {\n return `✅ Attendance noted, ${staff.name.split(' ')[0]}. Please update class attendance from the dashboard to keep records accurate.\n\n${school.name} 🏫`;\n }\n\n const teacherSystem = `You are EduPing, a school management assistant for ${school.name}. \nYou are speaking with ${staff.name}, a ${staff.role || 'teacher'}${staff.subject ? ' who teaches ' + staff.subject : ''}${staff.class ? ' for class ' + staff.class : ''}.\nHelp them with: logging scores, assigning homework, recording attendance, behaviour notes, or general school queries.\nKeep responses SHORT (under 100 words), practical, and clearly directed at the teacher — not a parent.\nNever send parent-style responses. End with ${school.name} 🏫`;\n\n return await callAI(teacherSystem, body || 'Hello', null);\n}\n\nfunction requireSuper(req, res, next) {\n const token = req.headers['x-super-admin-password'] || req.body.password || req.query.password;\n if (!process.env.SUPER_ADMIN_PASSWORD || token !== process.env.SUPER_ADMIN_PASSWORD) return bad(res, 'Unauthorized super admin', 401);\n next();\n}\nasync function requireSchool(req, res, next) {\n try { rateLimitDB(req.headers['x-school-id']); } catch(e) { return res.status(429).json({ error: e.message }); }\n const schoolId = req.headers['x-school-id'] || req.query.school_id || req.body.school_id;\n const password = req.headers['x-admin-password'] || req.body.admin_password; // never from query params — would appear in logs\n if (!schoolId || !password) return bad(res, 'Missing school_id or admin password', 401);\n const school = await getSchool(schoolId);\n if (!school || school.admin_password !== password) return bad(res, 'Unauthorized school admin', 401);\n req.school = school;\n next();\n}\n\n// root route handled above by landing.html\napp.get('/superadmin', (req, res) => res.sendFile(path.join(__dirname, 'public', 'superadmin.html')));\napp.get('/onboarding', (req, res) => res.sendFile(path.join(__dirname, 'public', 'onboarding.html')));\napp.get('/privacy', (req, res) => {\n res.send(`\n\n
\n\nEduPing ("we", "us", "our") operates an AI-powered school communication platform accessible via WhatsApp and web. This policy explains how we collect, use, and protect your data.
\n\nYour school data and parent messages are processed by AI systems (including OpenAI and/or DeepSeek) to generate responses. Data sent to AI providers is subject to their respective privacy policies. We do not use your data to train AI models.
\n\nWe do not sell your data to third parties. Data is shared only with:
\nAll data is stored on encrypted PostgreSQL databases hosted on Railway. Passwords are hashed using bcrypt. All connections use HTTPS/TLS encryption. Access to school data is protected by JWT authentication and school-specific credentials.
\n\nWe retain school and student data for the duration of the active subscription. Upon cancellation, data is retained for 90 days then permanently deleted upon written request.
\n\nYou have the right to access, correct, or delete your data at any time. Contact us at buikephilip@gmail.com with any data requests.
\n\nEduPing processes student data on behalf of schools. Schools are responsible for obtaining appropriate consent from parents and guardians as required by applicable law.
\n\nWe may update this policy from time to time. Schools will be notified of significant changes via WhatsApp or email.
\n\nPhilip Buike — EduPing
\nEmail: buikephilip@gmail.com
\nPhone: 07015255068
\nWebsite: eduping.org
${g.school_name} · ${photos.rows.length} photos
\n| Subject | Score | Grade | Remark |
|---|
EduPing ("we", "us", "our") operates an AI-powered school communication platform accessible via WhatsApp and web. This policy explains how we collect, use, and protect your data.
\n\nYour school data and parent messages are processed by AI systems (including OpenAI and/or DeepSeek) to generate responses. Data sent to AI providers is subject to their respective privacy policies. We do not use your data to train AI models.
\n\nWe do not sell your data to third parties. Data is shared only with:
\nAll data is stored on encrypted PostgreSQL databases hosted on Railway. Passwords are hashed using bcrypt. All connections use HTTPS/TLS encryption. Access to school data is protected by JWT authentication and school-specific credentials.
\n\nWe retain school and student data for the duration of the active subscription. Upon cancellation, data is retained for 90 days then permanently deleted upon written request.
\n\nYou have the right to access, correct, or delete your data at any time. Contact us at buikephilip@gmail.com with any data requests.
\n\nEduPing processes student data on behalf of schools. Schools are responsible for obtaining appropriate consent from parents and guardians as required by applicable law.
\n\nWe may update this policy from time to time. Schools will be notified of significant changes via WhatsApp or email.
\n\nPhilip Buike — EduPing
\nEmail: buikephilip@gmail.com
\nPhone: 07015255068
\nWebsite: eduping.org
${g.school_name} · ${photos.rows.length} photos
\n| Subject | Score | Grade | Remark |
|---|
EduPing ("we", "us", "our") operates an AI-powered school communication platform accessible via WhatsApp and web. This policy explains how we collect, use, and protect your data.
\n\nYour school data and parent messages are processed by AI systems (including OpenAI and/or DeepSeek) to generate responses. Data sent to AI providers is subject to their respective privacy policies. We do not use your data to train AI models.
\n\nWe do not sell your data to third parties. Data is shared only with:
\nAll data is stored on encrypted PostgreSQL databases hosted on Railway. Passwords are hashed using bcrypt. All connections use HTTPS/TLS encryption. Access to school data is protected by JWT authentication and school-specific credentials.
\n\nWe retain school and student data for the duration of the active subscription. Upon cancellation, data is retained for 90 days then permanently deleted upon written request.
\n\nYou have the right to access, correct, or delete your data at any time. Contact us at buikephilip@gmail.com with any data requests.
\n\nEduPing processes student data on behalf of schools. Schools are responsible for obtaining appropriate consent from parents and guardians as required by applicable law.
\n\nWe may update this policy from time to time. Schools will be notified of significant changes via WhatsApp or email.
\n\nPhilip Buike — EduPing
\nEmail: buikephilip@gmail.com
\nPhone: 07015255068
\nWebsite: eduping.org
${g.school_name} · ${photos.rows.length} photos
\n| Subject | Score | Grade | Remark |
|---|
EduPing ("we", "us", "our") operates an AI-powered school communication platform accessible via WhatsApp and web. This policy explains how we collect, use, and protect your data.
\n\nYour school data and parent messages are processed by AI systems (including OpenAI and/or DeepSeek) to generate responses. Data sent to AI providers is subject to their respective privacy policies. We do not use your data to train AI models.
\n\nWe do not sell your data to third parties. Data is shared only with:
\nAll data is stored on encrypted PostgreSQL databases hosted on Railway. Passwords are hashed using bcrypt. All connections use HTTPS/TLS encryption. Access to school data is protected by JWT authentication and school-specific credentials.
\n\nWe retain school and student data for the duration of the active subscription. Upon cancellation, data is retained for 90 days then permanently deleted upon written request.
\n\nYou have the right to access, correct, or delete your data at any time. Contact us at buikephilip@gmail.com with any data requests.
\n\nEduPing processes student data on behalf of schools. Schools are responsible for obtaining appropriate consent from parents and guardians as required by applicable law.
\n\nWe may update this policy from time to time. Schools will be notified of significant changes via WhatsApp or email.
\n\nPhilip Buike — EduPing
\nEmail: buikephilip@gmail.com
\nPhone: 07015255068
\nWebsite: eduping.org
${g.school_name} · ${photos.rows.length} photos
\n| Subject | Score | Grade | Remark |
|---|
EduPing ("we", "us", "our") operates an AI-powered school communication platform accessible via WhatsApp and web. This policy explains how we collect, use, and protect your data.
\n\nYour school data and parent messages are processed by AI systems (including OpenAI and/or DeepSeek) to generate responses. Data sent to AI providers is subject to their respective privacy policies. We do not use your data to train AI models.
\n\nWe do not sell your data to third parties. Data is shared only with:
\nAll data is stored on encrypted PostgreSQL databases hosted on Railway. Passwords are hashed using bcrypt. All connections use HTTPS/TLS encryption. Access to school data is protected by JWT authentication and school-specific credentials.
\n\nWe retain school and student data for the duration of the active subscription. Upon cancellation, data is retained for 90 days then permanently deleted upon written request.
\n\nYou have the right to access, correct, or delete your data at any time. Contact us at buikephilip@gmail.com with any data requests.
\n\nEduPing processes student data on behalf of schools. Schools are responsible for obtaining appropriate consent from parents and guardians as required by applicable law.
\n\nWe may update this policy from time to time. Schools will be notified of significant changes via WhatsApp or email.
\n\nPhilip Buike — EduPing
\nEmail: buikephilip@gmail.com
\nPhone: 07015255068
\nWebsite: eduping.org
${g.school_name} · ${photos.rows.length} photos
\n| Subject | Score | Grade | Remark |
|---|
EduPing ("we", "us", "our") operates an AI-powered school communication platform accessible via WhatsApp and web. This policy explains how we collect, use, and protect your data.
\n\nYour school data and parent messages are processed by AI systems (including OpenAI and/or DeepSeek) to generate responses. Data sent to AI providers is subject to their respective privacy policies. We do not use your data to train AI models.
\n\nWe do not sell your data to third parties. Data is shared only with:
\nAll data is stored on encrypted PostgreSQL databases hosted on Railway. Passwords are hashed using bcrypt. All connections use HTTPS/TLS encryption. Access to school data is protected by JWT authentication and school-specific credentials.
\n\nWe retain school and student data for the duration of the active subscription. Upon cancellation, data is retained for 90 days then permanently deleted upon written request.
\n\nYou have the right to access, correct, or delete your data at any time. Contact us at buikephilip@gmail.com with any data requests.
\n\nEduPing processes student data on behalf of schools. Schools are responsible for obtaining appropriate consent from parents and guardians as required by applicable law.
\n\nWe may update this policy from time to time. Schools will be notified of significant changes via WhatsApp or email.
\n\nPhilip Buike — EduPing
\nEmail: buikephilip@gmail.com
\nPhone: 07015255068
\nWebsite: eduping.org
${g.school_name} · ${photos.rows.length} photos
\n| Subject | Score | Grade | Remark |
|---|
EduPing ("we", "us", "our") operates an AI-powered school communication platform accessible via WhatsApp and web. This policy explains how we collect, use, and protect your data.
\n\nYour school data and parent messages are processed by AI systems (including OpenAI and/or DeepSeek) to generate responses. Data sent to AI providers is subject to their respective privacy policies. We do not use your data to train AI models.
\n\nWe do not sell your data to third parties. Data is shared only with:
\nAll data is stored on encrypted PostgreSQL databases hosted on Railway. Passwords are hashed using bcrypt. All connections use HTTPS/TLS encryption. Access to school data is protected by JWT authentication and school-specific credentials.
\n\nWe retain school and student data for the duration of the active subscription. Upon cancellation, data is retained for 90 days then permanently deleted upon written request.
\n\nYou have the right to access, correct, or delete your data at any time. Contact us at buikephilip@gmail.com with any data requests.
\n\nEduPing processes student data on behalf of schools. Schools are responsible for obtaining appropriate consent from parents and guardians as required by applicable law.
\n\nWe may update this policy from time to time. Schools will be notified of significant changes via WhatsApp or email.
\n\nPhilip Buike — EduPing
\nEmail: buikephilip@gmail.com
\nPhone: 07015255068
\nWebsite: eduping.org
${g.school_name} · ${photos.rows.length} photos
\n| Subject | Score | Grade | Remark |
|---|
EduPing ("we", "us", "our") operates an AI-powered school communication platform accessible via WhatsApp and web. This policy explains how we collect, use, and protect your data.
\n\nYour school data and parent messages are processed by AI systems (including OpenAI and/or DeepSeek) to generate responses. Data sent to AI providers is subject to their respective privacy policies. We do not use your data to train AI models.
\n\nWe do not sell your data to third parties. Data is shared only with:
\nAll data is stored on encrypted PostgreSQL databases hosted on Railway. Passwords are hashed using bcrypt. All connections use HTTPS/TLS encryption. Access to school data is protected by JWT authentication and school-specific credentials.
\n\nWe retain school and student data for the duration of the active subscription. Upon cancellation, data is retained for 90 days then permanently deleted upon written request.
\n\nYou have the right to access, correct, or delete your data at any time. Contact us at buikephilip@gmail.com with any data requests.
\n\nEduPing processes student data on behalf of schools. Schools are responsible for obtaining appropriate consent from parents and guardians as required by applicable law.
\n\nWe may update this policy from time to time. Schools will be notified of significant changes via WhatsApp or email.
\n\nPhilip Buike — EduPing
\nEmail: buikephilip@gmail.com
\nPhone: 07015255068
\nWebsite: eduping.org
${g.school_name} · ${photos.rows.length} photos
\n| Subject | Score | Grade | Remark |
|---|
EduPing ("we", "us", "our") operates an AI-powered school communication platform accessible via WhatsApp and web. This policy explains how we collect, use, and protect your data.
\n\nYour school data and parent messages are processed by AI systems (including OpenAI and/or DeepSeek) to generate responses. Data sent to AI providers is subject to their respective privacy policies. We do not use your data to train AI models.
\n\nWe do not sell your data to third parties. Data is shared only with:
\nAll data is stored on encrypted PostgreSQL databases hosted on Railway. Passwords are hashed using bcrypt. All connections use HTTPS/TLS encryption. Access to school data is protected by JWT authentication and school-specific credentials.
\n\nWe retain school and student data for the duration of the active subscription. Upon cancellation, data is retained for 90 days then permanently deleted upon written request.
\n\nYou have the right to access, correct, or delete your data at any time. Contact us at buikephilip@gmail.com with any data requests.
\n\nEduPing processes student data on behalf of schools. Schools are responsible for obtaining appropriate consent from parents and guardians as required by applicable law.
\n\nWe may update this policy from time to time. Schools will be notified of significant changes via WhatsApp or email.
\n\nPhilip Buike — EduPing
\nEmail: buikephilip@gmail.com
\nPhone: 07015255068
\nWebsite: eduping.org
${g.school_name} · ${photos.rows.length} photos
\n| Subject | Score | Grade | Remark |
|---|
EduPing ("we", "us", "our") operates an AI-powered school communication platform accessible via WhatsApp and web. This policy explains how we collect, use, and protect your data.
\n\nYour school data and parent messages are processed by AI systems (including OpenAI and/or DeepSeek) to generate responses. Data sent to AI providers is subject to their respective privacy policies. We do not use your data to train AI models.
\n\nWe do not sell your data to third parties. Data is shared only with:
\nAll data is stored on encrypted PostgreSQL databases hosted on Railway. Passwords are hashed using bcrypt. All connections use HTTPS/TLS encryption. Access to school data is protected by JWT authentication and school-specific credentials.
\n\nWe retain school and student data for the duration of the active subscription. Upon cancellation, data is retained for 90 days then permanently deleted upon written request.
\n\nYou have the right to access, correct, or delete your data at any time. Contact us at buikephilip@gmail.com with any data requests.
\n\nEduPing processes student data on behalf of schools. Schools are responsible for obtaining appropriate consent from parents and guardians as required by applicable law.
\n\nWe may update this policy from time to time. Schools will be notified of significant changes via WhatsApp or email.
\n\nPhilip Buike — EduPing
\nEmail: buikephilip@gmail.com
\nPhone: 07015255068
\nWebsite: eduping.org
${g.school_name} · ${photos.rows.length} photos
\n| Subject | Score | Grade | Remark |
|---|
EduPing ("we", "us", "our") operates an AI-powered school communication platform accessible via WhatsApp and web. This policy explains how we collect, use, and protect your data.
\n\nYour school data and parent messages are processed by AI systems (including OpenAI and/or DeepSeek) to generate responses. Data sent to AI providers is subject to their respective privacy policies. We do not use your data to train AI models.
\n\nWe do not sell your data to third parties. Data is shared only with:
\nAll data is stored on encrypted PostgreSQL databases hosted on Railway. Passwords are hashed using bcrypt. All connections use HTTPS/TLS encryption. Access to school data is protected by JWT authentication and school-specific credentials.
\n\nWe retain school and student data for the duration of the active subscription. Upon cancellation, data is retained for 90 days then permanently deleted upon written request.
\n\nYou have the right to access, correct, or delete your data at any time. Contact us at buikephilip@gmail.com with any data requests.
\n\nEduPing processes student data on behalf of schools. Schools are responsible for obtaining appropriate consent from parents and guardians as required by applicable law.
\n\nWe may update this policy from time to time. Schools will be notified of significant changes via WhatsApp or email.
\n\nPhilip Buike — EduPing
\nEmail: buikephilip@gmail.com
\nPhone: 07015255068
\nWebsite: eduping.org
${g.school_name} · ${photos.rows.length} photos
\n| Subject | Score | Grade | Remark |
|---|
EduPing ("we", "us", "our") operates an AI-powered school communication platform accessible via WhatsApp and web. This policy explains how we collect, use, and protect your data.
\n\nYour school data and parent messages are processed by AI systems (including OpenAI and/or DeepSeek) to generate responses. Data sent to AI providers is subject to their respective privacy policies. We do not use your data to train AI models.
\n\nWe do not sell your data to third parties. Data is shared only with:
\nAll data is stored on encrypted PostgreSQL databases hosted on Railway. Passwords are hashed using bcrypt. All connections use HTTPS/TLS encryption. Access to school data is protected by JWT authentication and school-specific credentials.
\n\nWe retain school and student data for the duration of the active subscription. Upon cancellation, data is retained for 90 days then permanently deleted upon written request.
\n\nYou have the right to access, correct, or delete your data at any time. Contact us at buikephilip@gmail.com with any data requests.
\n\nEduPing processes student data on behalf of schools. Schools are responsible for obtaining appropriate consent from parents and guardians as required by applicable law.
\n\nWe may update this policy from time to time. Schools will be notified of significant changes via WhatsApp or email.
\n\nPhilip Buike — EduPing
\nEmail: buikephilip@gmail.com
\nPhone: 07015255068
\nWebsite: eduping.org
${g.school_name} · ${photos.rows.length} photos
\n| Subject | Score | Grade | Remark |
|---|
EduPing ("we", "us", "our") operates an AI-powered school communication platform accessible via WhatsApp and web. This policy explains how we collect, use, and protect your data.
\n\nYour school data and parent messages are processed by AI systems (including OpenAI and/or DeepSeek) to generate responses. Data sent to AI providers is subject to their respective privacy policies. We do not use your data to train AI models.
\n\nWe do not sell your data to third parties. Data is shared only with:
\nAll data is stored on encrypted PostgreSQL databases hosted on Railway. Passwords are hashed using bcrypt. All connections use HTTPS/TLS encryption. Access to school data is protected by JWT authentication and school-specific credentials.
\n\nWe retain school and student data for the duration of the active subscription. Upon cancellation, data is retained for 90 days then permanently deleted upon written request.
\n\nYou have the right to access, correct, or delete your data at any time. Contact us at buikephilip@gmail.com with any data requests.
\n\nEduPing processes student data on behalf of schools. Schools are responsible for obtaining appropriate consent from parents and guardians as required by applicable law.
\n\nWe may update this policy from time to time. Schools will be notified of significant changes via WhatsApp or email.
\n\nPhilip Buike — EduPing
\nEmail: buikephilip@gmail.com
\nPhone: 07015255068
\nWebsite: eduping.org
${g.school_name} · ${photos.rows.length} photos
\n| Subject | Score | Grade | Remark |
|---|
EduPing ("we", "us", "our") operates an AI-powered school communication platform accessible via WhatsApp and web. This policy explains how we collect, use, and protect your data.
\n\nYour school data and parent messages are processed by AI systems (including OpenAI and/or DeepSeek) to generate responses. Data sent to AI providers is subject to their respective privacy policies. We do not use your data to train AI models.
\n\nWe do not sell your data to third parties. Data is shared only with:
\nAll data is stored on encrypted PostgreSQL databases hosted on Railway. Passwords are hashed using bcrypt. All connections use HTTPS/TLS encryption. Access to school data is protected by JWT authentication and school-specific credentials.
\n\nWe retain school and student data for the duration of the active subscription. Upon cancellation, data is retained for 90 days then permanently deleted upon written request.
\n\nYou have the right to access, correct, or delete your data at any time. Contact us at buikephilip@gmail.com with any data requests.
\n\nEduPing processes student data on behalf of schools. Schools are responsible for obtaining appropriate consent from parents and guardians as required by applicable law.
\n\nWe may update this policy from time to time. Schools will be notified of significant changes via WhatsApp or email.
\n\nPhilip Buike — EduPing
\nEmail: buikephilip@gmail.com
\nPhone: 07015255068
\nWebsite: eduping.org
${g.school_name} · ${photos.rows.length} photos
\n| Subject | Score | Grade | Remark |
|---|
EduPing ("we", "us", "our") operates an AI-powered school communication platform accessible via WhatsApp and web. This policy explains how we collect, use, and protect your data.
\n\nYour school data and parent messages are processed by AI systems (including OpenAI and/or DeepSeek) to generate responses. Data sent to AI providers is subject to their respective privacy policies. We do not use your data to train AI models.
\n\nWe do not sell your data to third parties. Data is shared only with:
\nAll data is stored on encrypted PostgreSQL databases hosted on Railway. Passwords are hashed using bcrypt. All connections use HTTPS/TLS encryption. Access to school data is protected by JWT authentication and school-specific credentials.
\n\nWe retain school and student data for the duration of the active subscription. Upon cancellation, data is retained for 90 days then permanently deleted upon written request.
\n\nYou have the right to access, correct, or delete your data at any time. Contact us at buikephilip@gmail.com with any data requests.
\n\nEduPing processes student data on behalf of schools. Schools are responsible for obtaining appropriate consent from parents and guardians as required by applicable law.
\n\nWe may update this policy from time to time. Schools will be notified of significant changes via WhatsApp or email.
\n\nPhilip Buike — EduPing
\nEmail: buikephilip@gmail.com
\nPhone: 07015255068
\nWebsite: eduping.org
${g.school_name} · ${photos.rows.length} photos
\n| Subject | Score | Grade | Remark |
|---|
EduPing ("we", "us", "our") operates an AI-powered school communication platform accessible via WhatsApp and web. This policy explains how we collect, use, and protect your data.
\n\nYour school data and parent messages are processed by AI systems (including OpenAI and/or DeepSeek) to generate responses. Data sent to AI providers is subject to their respective privacy policies. We do not use your data to train AI models.
\n\nWe do not sell your data to third parties. Data is shared only with:
\nAll data is stored on encrypted PostgreSQL databases hosted on Railway. Passwords are hashed using bcrypt. All connections use HTTPS/TLS encryption. Access to school data is protected by JWT authentication and school-specific credentials.
\n\nWe retain school and student data for the duration of the active subscription. Upon cancellation, data is retained for 90 days then permanently deleted upon written request.
\n\nYou have the right to access, correct, or delete your data at any time. Contact us at buikephilip@gmail.com with any data requests.
\n\nEduPing processes student data on behalf of schools. Schools are responsible for obtaining appropriate consent from parents and guardians as required by applicable law.
\n\nWe may update this policy from time to time. Schools will be notified of significant changes via WhatsApp or email.
\n\nPhilip Buike — EduPing
\nEmail: buikephilip@gmail.com
\nPhone: 07015255068
\nWebsite: eduping.org
${g.school_name} · ${photos.rows.length} photos
\n| Subject | Score | Grade | Remark |
|---|
EduPing ("we", "us", "our") operates an AI-powered school communication platform accessible via WhatsApp and web. This policy explains how we collect, use, and protect your data.
\n\nYour school data and parent messages are processed by AI systems (including OpenAI and/or DeepSeek) to generate responses. Data sent to AI providers is subject to their respective privacy policies. We do not use your data to train AI models.
\n\nWe do not sell your data to third parties. Data is shared only with:
\nAll data is stored on encrypted PostgreSQL databases hosted on Railway. Passwords are hashed using bcrypt. All connections use HTTPS/TLS encryption. Access to school data is protected by JWT authentication and school-specific credentials.
\n\nWe retain school and student data for the duration of the active subscription. Upon cancellation, data is retained for 90 days then permanently deleted upon written request.
\n\nYou have the right to access, correct, or delete your data at any time. Contact us at buikephilip@gmail.com with any data requests.
\n\nEduPing processes student data on behalf of schools. Schools are responsible for obtaining appropriate consent from parents and guardians as required by applicable law.
\n\nWe may update this policy from time to time. Schools will be notified of significant changes via WhatsApp or email.
\n\nPhilip Buike — EduPing
\nEmail: buikephilip@gmail.com
\nPhone: 07015255068
\nWebsite: eduping.org
${g.school_name} · ${photos.rows.length} photos
\n| Subject | Score | Grade | Remark |
|---|
EduPing ("we", "us", "our") operates an AI-powered school communication platform accessible via WhatsApp and web. This policy explains how we collect, use, and protect your data.
\n\nYour school data and parent messages are processed by AI systems (including OpenAI and/or DeepSeek) to generate responses. Data sent to AI providers is subject to their respective privacy policies. We do not use your data to train AI models.
\n\nWe do not sell your data to third parties. Data is shared only with:
\nAll data is stored on encrypted PostgreSQL databases hosted on Railway. Passwords are hashed using bcrypt. All connections use HTTPS/TLS encryption. Access to school data is protected by JWT authentication and school-specific credentials.
\n\nWe retain school and student data for the duration of the active subscription. Upon cancellation, data is retained for 90 days then permanently deleted upon written request.
\n\nYou have the right to access, correct, or delete your data at any time. Contact us at buikephilip@gmail.com with any data requests.
\n\nEduPing processes student data on behalf of schools. Schools are responsible for obtaining appropriate consent from parents and guardians as required by applicable law.
\n\nWe may update this policy from time to time. Schools will be notified of significant changes via WhatsApp or email.
\n\nPhilip Buike — EduPing
\nEmail: buikephilip@gmail.com
\nPhone: 07015255068
\nWebsite: eduping.org
${g.school_name} · ${photos.rows.length} photos
\n| Subject | Score | Grade | Remark |
|---|
EduPing ("we", "us", "our") operates an AI-powered school communication platform accessible via WhatsApp and web. This policy explains how we collect, use, and protect your data.
\n\nYour school data and parent messages are processed by AI systems (including OpenAI and/or DeepSeek) to generate responses. Data sent to AI providers is subject to their respective privacy policies. We do not use your data to train AI models.
\n\nWe do not sell your data to third parties. Data is shared only with:
\nAll data is stored on encrypted PostgreSQL databases hosted on Railway. Passwords are hashed using bcrypt. All connections use HTTPS/TLS encryption. Access to school data is protected by JWT authentication and school-specific credentials.
\n\nWe retain school and student data for the duration of the active subscription. Upon cancellation, data is retained for 90 days then permanently deleted upon written request.
\n\nYou have the right to access, correct, or delete your data at any time. Contact us at buikephilip@gmail.com with any data requests.
\n\nEduPing processes student data on behalf of schools. Schools are responsible for obtaining appropriate consent from parents and guardians as required by applicable law.
\n\nWe may update this policy from time to time. Schools will be notified of significant changes via WhatsApp or email.
\n\nPhilip Buike — EduPing
\nEmail: buikephilip@gmail.com
\nPhone: 07015255068
\nWebsite: eduping.org
${g.school_name} · ${photos.rows.length} photos
\n| Subject | Score | Grade | Remark |
|---|
EduPing ("we", "us", "our") operates an AI-powered school communication platform accessible via WhatsApp and web. This policy explains how we collect, use, and protect your data.
\n\nYour school data and parent messages are processed by AI systems (including OpenAI and/or DeepSeek) to generate responses. Data sent to AI providers is subject to their respective privacy policies. We do not use your data to train AI models.
\n\nWe do not sell your data to third parties. Data is shared only with:
\nAll data is stored on encrypted PostgreSQL databases hosted on Railway. Passwords are hashed using bcrypt. All connections use HTTPS/TLS encryption. Access to school data is protected by JWT authentication and school-specific credentials.
\n\nWe retain school and student data for the duration of the active subscription. Upon cancellation, data is retained for 90 days then permanently deleted upon written request.
\n\nYou have the right to access, correct, or delete your data at any time. Contact us at buikephilip@gmail.com with any data requests.
\n\nEduPing processes student data on behalf of schools. Schools are responsible for obtaining appropriate consent from parents and guardians as required by applicable law.
\n\nWe may update this policy from time to time. Schools will be notified of significant changes via WhatsApp or email.
\n\nPhilip Buike — EduPing
\nEmail: buikephilip@gmail.com
\nPhone: 07015255068
\nWebsite: eduping.org
${g.school_name} · ${photos.rows.length} photos
\nEduPing ("we", "us", "our") operates an AI-powered school communication platform accessible via WhatsApp and web. This policy explains how we collect, use, and protect your data.
\n\nYour school data and parent messages are processed by AI systems (including OpenAI and/or DeepSeek) to generate responses. Data sent to AI providers is subject to their respective privacy policies. We do not use your data to train AI models.
\n\nWe do not sell your data to third parties. Data is shared only with:
\nAll data is stored on encrypted PostgreSQL databases hosted on Railway. Passwords are hashed using bcrypt. All connections use HTTPS/TLS encryption. Access to school data is protected by JWT authentication and school-specific credentials.
\n\nWe retain school and student data for the duration of the active subscription. Upon cancellation, data is retained for 90 days then permanently deleted upon written request.
\n\nYou have the right to access, correct, or delete your data at any time. Contact us at buikephilip@gmail.com with any data requests.
\n\nEduPing processes student data on behalf of schools. Schools are responsible for obtaining appropriate consent from parents and guardians as required by applicable law.
\n\nWe may update this policy from time to time. Schools will be notified of significant changes via WhatsApp or email.
\n\nPhilip Buike — EduPing
\nEmail: buikephilip@gmail.com
\nPhone: 07015255068
\nWebsite: eduping.org
${g.school_name} · ${photos.rows.length} photos
\nEduPing ("we", "us", "our") operates an AI-powered school communication platform accessible via WhatsApp and web. This policy explains how we collect, use, and protect your data.
\n\nYour school data and parent messages are processed by AI systems (including OpenAI and/or DeepSeek) to generate responses. Data sent to AI providers is subject to their respective privacy policies. We do not use your data to train AI models.
\n\nWe do not sell your data to third parties. Data is shared only with:
\nAll data is stored on encrypted PostgreSQL databases hosted on Railway. Passwords are hashed using bcrypt. All connections use HTTPS/TLS encryption. Access to school data is protected by JWT authentication and school-specific credentials.
\n\nWe retain school and student data for the duration of the active subscription. Upon cancellation, data is retained for 90 days then permanently deleted upon written request.
\n\nYou have the right to access, correct, or delete your data at any time. Contact us at buikephilip@gmail.com with any data requests.
\n\nEduPing processes student data on behalf of schools. Schools are responsible for obtaining appropriate consent from parents and guardians as required by applicable law.
\n\nWe may update this policy from time to time. Schools will be notified of significant changes via WhatsApp or email.
\n\nPhilip Buike — EduPing
\nEmail: buikephilip@gmail.com
\nPhone: 07015255068
\nWebsite: eduping.org
${g.school_name} · ${photos.rows.length} photos
\nEduPing ("we", "us", "our") operates an AI-powered school communication platform accessible via WhatsApp and web. This policy explains how we collect, use, and protect your data.
\n\nYour school data and parent messages are processed by AI systems (including OpenAI and/or DeepSeek) to generate responses. Data sent to AI providers is subject to their respective privacy policies. We do not use your data to train AI models.
\n\nWe do not sell your data to third parties. Data is shared only with:
\nAll data is stored on encrypted PostgreSQL databases hosted on Railway. Passwords are hashed using bcrypt. All connections use HTTPS/TLS encryption. Access to school data is protected by JWT authentication and school-specific credentials.
\n\nWe retain school and student data for the duration of the active subscription. Upon cancellation, data is retained for 90 days then permanently deleted upon written request.
\n\nYou have the right to access, correct, or delete your data at any time. Contact us at buikephilip@gmail.com with any data requests.
\n\nEduPing processes student data on behalf of schools. Schools are responsible for obtaining appropriate consent from parents and guardians as required by applicable law.
\n\nWe may update this policy from time to time. Schools will be notified of significant changes via WhatsApp or email.
\n\nPhilip Buike — EduPing
\nEmail: buikephilip@gmail.com
\nPhone: 07015255068
\nWebsite: eduping.org
${g.school_name} · ${photos.rows.length} photos
\nEduPing ("we", "us", "our") operates an AI-powered school communication platform accessible via WhatsApp and web. This policy explains how we collect, use, and protect your data.
\n\nYour school data and parent messages are processed by AI systems (including OpenAI and/or DeepSeek) to generate responses. Data sent to AI providers is subject to their respective privacy policies. We do not use your data to train AI models.
\n\nWe do not sell your data to third parties. Data is shared only with:
\nAll data is stored on encrypted PostgreSQL databases hosted on Railway. Passwords are hashed using bcrypt. All connections use HTTPS/TLS encryption. Access to school data is protected by JWT authentication and school-specific credentials.
\n\nWe retain school and student data for the duration of the active subscription. Upon cancellation, data is retained for 90 days then permanently deleted upon written request.
\n\nYou have the right to access, correct, or delete your data at any time. Contact us at buikephilip@gmail.com with any data requests.
\n\nEduPing processes student data on behalf of schools. Schools are responsible for obtaining appropriate consent from parents and guardians as required by applicable law.
\n\nWe may update this policy from time to time. Schools will be notified of significant changes via WhatsApp or email.
\n\nPhilip Buike — EduPing
\nEmail: buikephilip@gmail.com
\nPhone: 07015255068
\nWebsite: eduping.org
${g.school_name} · ${photos.rows.length} photos
\nEduPing ("we", "us", "our") operates an AI-powered school communication platform accessible via WhatsApp and web. This policy explains how we collect, use, and protect your data.
\n\nYour school data and parent messages are processed by AI systems (including OpenAI and/or DeepSeek) to generate responses. Data sent to AI providers is subject to their respective privacy policies. We do not use your data to train AI models.
\n\nWe do not sell your data to third parties. Data is shared only with:
\nAll data is stored on encrypted PostgreSQL databases hosted on Railway. Passwords are hashed using bcrypt. All connections use HTTPS/TLS encryption. Access to school data is protected by JWT authentication and school-specific credentials.
\n\nWe retain school and student data for the duration of the active subscription. Upon cancellation, data is retained for 90 days then permanently deleted upon written request.
\n\nYou have the right to access, correct, or delete your data at any time. Contact us at buikephilip@gmail.com with any data requests.
\n\nEduPing processes student data on behalf of schools. Schools are responsible for obtaining appropriate consent from parents and guardians as required by applicable law.
\n\nWe may update this policy from time to time. Schools will be notified of significant changes via WhatsApp or email.
\n\nPhilip Buike — EduPing
\nEmail: buikephilip@gmail.com
\nPhone: 07015255068
\nWebsite: eduping.org
${g.school_name} · ${photos.rows.length} photos
\nEduPing ("we", "us", "our") operates an AI-powered school communication platform accessible via WhatsApp and web. This policy explains how we collect, use, and protect your data.
\n\nYour school data and parent messages are processed by AI systems (including OpenAI and/or DeepSeek) to generate responses. Data sent to AI providers is subject to their respective privacy policies. We do not use your data to train AI models.
\n\nWe do not sell your data to third parties. Data is shared only with:
\nAll data is stored on encrypted PostgreSQL databases hosted on Railway. Passwords are hashed using bcrypt. All connections use HTTPS/TLS encryption. Access to school data is protected by JWT authentication and school-specific credentials.
\n\nWe retain school and student data for the duration of the active subscription. Upon cancellation, data is retained for 90 days then permanently deleted upon written request.
\n\nYou have the right to access, correct, or delete your data at any time. Contact us at buikephilip@gmail.com with any data requests.
\n\nEduPing processes student data on behalf of schools. Schools are responsible for obtaining appropriate consent from parents and guardians as required by applicable law.
\n\nWe may update this policy from time to time. Schools will be notified of significant changes via WhatsApp or email.
\n\nPhilip Buike — EduPing
\nEmail: buikephilip@gmail.com
\nPhone: 07015255068
\nWebsite: eduping.org
${g.school_name} · ${photos.rows.length} photos
\nEduPing ("we", "us", "our") operates an AI-powered school communication platform accessible via WhatsApp and web. This policy explains how we collect, use, and protect your data.
\n\nYour school data and parent messages are processed by AI systems (including OpenAI and/or DeepSeek) to generate responses. Data sent to AI providers is subject to their respective privacy policies. We do not use your data to train AI models.
\n\nWe do not sell your data to third parties. Data is shared only with:
\nAll data is stored on encrypted PostgreSQL databases hosted on Railway. Passwords are hashed using bcrypt. All connections use HTTPS/TLS encryption. Access to school data is protected by JWT authentication and school-specific credentials.
\n\nWe retain school and student data for the duration of the active subscription. Upon cancellation, data is retained for 90 days then permanently deleted upon written request.
\n\nYou have the right to access, correct, or delete your data at any time. Contact us at buikephilip@gmail.com with any data requests.
\n\nEduPing processes student data on behalf of schools. Schools are responsible for obtaining appropriate consent from parents and guardians as required by applicable law.
\n\nWe may update this policy from time to time. Schools will be notified of significant changes via WhatsApp or email.
\n\nPhilip Buike — EduPing
\nEmail: buikephilip@gmail.com
\nPhone: 07015255068
\nWebsite: eduping.org
EduPing ("we", "us", "our") operates an AI-powered school communication platform accessible via WhatsApp and web. This policy explains how we collect, use, and protect your data.
\n\nYour school data and parent messages are processed by AI systems (including OpenAI and/or DeepSeek) to generate responses. Data sent to AI providers is subject to their respective privacy policies. We do not use your data to train AI models.
\n\nWe do not sell your data to third parties. Data is shared only with:
\nAll data is stored on encrypted PostgreSQL databases hosted on Railway. Passwords are hashed using bcrypt. All connections use HTTPS/TLS encryption. Access to school data is protected by JWT authentication and school-specific credentials.
\n\nWe retain school and student data for the duration of the active subscription. Upon cancellation, data is retained for 90 days then permanently deleted upon written request.
\n\nYou have the right to access, correct, or delete your data at any time. Contact us at buikephilip@gmail.com with any data requests.
\n\nEduPing processes student data on behalf of schools. Schools are responsible for obtaining appropriate consent from parents and guardians as required by applicable law.
\n\nWe may update this policy from time to time. Schools will be notified of significant changes via WhatsApp or email.
\n\nPhilip Buike — EduPing
\nEmail: buikephilip@gmail.com
\nPhone: 07015255068
\nWebsite: eduping.org
EduPing ("we", "us", "our") operates an AI-powered school communication platform accessible via WhatsApp and web. This policy explains how we collect, use, and protect your data.
\n\nYour school data and parent messages are processed by AI systems (including OpenAI and/or DeepSeek) to generate responses. Data sent to AI providers is subject to their respective privacy policies. We do not use your data to train AI models.
\n\nWe do not sell your data to third parties. Data is shared only with:
\nAll data is stored on encrypted PostgreSQL databases hosted on Railway. Passwords are hashed using bcrypt. All connections use HTTPS/TLS encryption. Access to school data is protected by JWT authentication and school-specific credentials.
\n\nWe retain school and student data for the duration of the active subscription. Upon cancellation, data is retained for 90 days then permanently deleted upon written request.
\n\nYou have the right to access, correct, or delete your data at any time. Contact us at buikephilip@gmail.com with any data requests.
\n\nEduPing processes student data on behalf of schools. Schools are responsible for obtaining appropriate consent from parents and guardians as required by applicable law.
\n\nWe may update this policy from time to time. Schools will be notified of significant changes via WhatsApp or email.
\n\nPhilip Buike — EduPing
\nEmail: buikephilip@gmail.com
\nPhone: 07015255068
\nWebsite: eduping.org