I Escondido·In 8 weeks (June 2026)

Developer Portal

developer.foracity.com

Complete architecture reference for the ForACity civic engagement platform. Everything Robert needs to understand, extend, and deploy the codebase.

React 19tRPC 11Drizzle ORMMySQL (TiDB)Tailwind 4Vitest

All ForACity subdomains share the same codebase and database. Routing is handled client-side.

bizdev.foracity.comlive

Business development — entity, compliance, contracts, market strategy

Route: /bizdev
developer.foracity.comlive

Developer portal — architecture, API docs, setup guide (this page)

Route: /developer
levineact-cozcqk6f.manus.spacelive

Main civic feed — social feed, officers, legislation, meetings

Route: /

These are all remaining items that need implementation. They are grouped by what's blocking them. Each includes the files to edit, code patterns, and step-by-step instructions.

1

Push Notification Service

Requires VAPID Keys

What: Browser push notifications via Web Push API + optional email digest. Users get alerted about upcoming meetings, new conflicts, and legislation matching their Values Compass profile.

Effort: ~4-6 hours

Step 1: Generate VAPID Keys

# Install web-push globally
npm install -g web-push

# Generate VAPID key pair
web-push generate-vapid-keys

# Add to Manus Settings → Secrets:
# VAPID_PUBLIC_KEY=BPxr...
# VAPID_PRIVATE_KEY=nD2x...
# VAPID_SUBJECT=mailto:[email protected]

Step 2: Add server-side push service

// server/pushNotification.ts
import webpush from 'web-push';
import { env } from './_core/env';

webpush.setVapidDetails(
  env.VAPID_SUBJECT,
  env.VAPID_PUBLIC_KEY,
  env.VAPID_PRIVATE_KEY
);

export async function sendPush(subscription: PushSubscription, payload: {
  title: string; body: string; url?: string;
}) {
  return webpush.sendNotification(subscription, JSON.stringify(payload));
}

Step 3: Add subscription table to schema

// drizzle/schema.ts — add:
export const pushSubscriptions = mysqlTable('push_subscriptions', {
  id: int('id').primaryKey().autoincrement(),
  userId: int('user_id').notNull(),
  endpoint: text('endpoint').notNull(),
  p256dh: text('p256dh').notNull(),
  auth: text('auth').notNull(),
  createdAt: timestamp('created_at').defaultNow(),
});
// Then run: pnpm db:push

Step 4: Add tRPC routes

// server/routers.ts — add to notificationRouter:
subscribe: protectedProcedure
  .input(z.object({
    endpoint: z.string(),
    keys: z.object({ p256dh: z.string(), auth: z.string() })
  }))
  .mutation(async ({ ctx, input }) => {
    // Save subscription to pushSubscriptions table
    // Return success
  }),
unsubscribe: protectedProcedure.mutation(async ({ ctx }) => {
  // Delete subscription for ctx.user.id
}),

Step 5: Frontend service worker

// client/public/sw.js
self.addEventListener('push', (event) => {
  const data = event.data.json();
  event.waitUntil(
    self.registration.showNotification(data.title, {
      body: data.body,
      icon: '/favicon.ico',
      data: { url: data.url }
    })
  );
});

self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  if (event.notification.data.url) {
    event.waitUntil(clients.openWindow(event.notification.data.url));
  }
});
Files to edit: drizzle/schema.ts, server/routers.ts (notificationRouter), server/pushNotification.ts (new), client/public/sw.js (new), client/src/pages/Home.tsx (register SW)
2

Facebook OAuth Activation

Requires FB App Registration

What: The Facebook connect flow is already fully coded in the backend and frontend. It just needs a real Facebook App ID to activate. This enables profile photo sync, friend discovery, and social sharing.

Effort: ~1-2 hours (mostly Facebook Developer Console setup)

Step 1: Register Facebook App

# Go to: https://developers.facebook.com/apps/
# Click "Create App" → "Consumer" type
# App name: ForACity
# Add product: "Facebook Login"
# Settings → Basic:
#   App ID: (copy this)
#   App Secret: (copy this)

Step 2: Configure OAuth Redirect

# In Facebook App → Facebook Login → Settings:
# Valid OAuth Redirect URIs:
https://foracity.com/api/facebook/callback
https://www.foracity.com/api/facebook/callback
https://levineact-cozcqk6f.manus.space/api/facebook/callback

Step 3: Set Environment Variables

# In Manus Settings → Secrets, add:
FACEBOOK_APP_ID=your_app_id_here
FACEBOOK_APP_SECRET=your_app_secret_here

Step 4: Test the Flow

# 1. Log in to ForACity
# 2. Go to your profile or settings
# 3. Click "Connect Facebook"
# 4. Authorize the app
# 5. Verify: profile photo syncs, friends appear
# 6. Test sharing a feed post to Facebook
Already coded: server/routers.ts (facebookRouter), client/src/pages/Home.tsx (share buttons), client/src/pages/OfficerProfile.tsx (friend activity). Just needs the env vars.
3

Automated NetFile Contribution Monitoring

5 Sub-tasks

What: Scheduled daily job that pulls new campaign filings from NetFile, auto-flags contributions exceeding the $500 Levine Act threshold, generates admin alerts, and creates feed posts for significant new contributions.

Effort: ~6-8 hours

Sub-task A: Scheduled Job (Daily NetFile Pull)

// server/jobs/netfileSync.ts
import { fetchNetFileTransactions } from '../netfile';
import { getOfficers, createContribution } from '../db';
import { notifyOwner } from '../_core/notification';

export async function runNetFileSync() {
  const officers = await getOfficers({ jurisdictionId: 1 }); // Escondido
  for (const officer of officers) {
    if (!officer.netfileFilerId) continue;
    const txns = await fetchNetFileTransactions(officer.netfileFilerId);
    // Filter new transactions since last sync
    // Insert into contributions table
    // Check for $500+ threshold
  }
  await notifyOwner({ title: 'NetFile Sync Complete', content: '...' });
}

// Trigger via cron or admin button

Sub-task B: Auto-Flag $500+ Contributions

// In the sync loop, after inserting contribution:
if (contribution.amount >= 250) {
  // Run conflict detection for this officer
  await detectConflicts(officer.id);
  // Create admin alert
  await createNotification({
    userId: adminUserId,
    type: 'conflict_alert',
    title: `High contribution: $${amount} to ${officer.name}`,
    body: `From ${contributorName}. Levine Act threshold exceeded.`,
  });
}

Sub-task C: Auto-Create Feed Posts

// After processing significant contributions:
await createFeedPost({
  postType: 'contribution',
  title: `New contribution: $${amount} to ${officer.name}`,
  body: `${contributorName} contributed $${amount}...`,
  jurisdictionId: officer.jurisdictionId,
  officerId: officer.id,
});

Sub-task D: Admin Dashboard Indicator

// The NetFile sync status indicator already exists in
// AdminDashboard.tsx and AdminNetFile.tsx
// Just need to update the lastSync timestamp after each run:
await updateNetFileSyncStatus(officerId, {
  lastSync: new Date(),
  recordCount: newRecords,
  status: 'success',
});
Files to edit: server/jobs/netfileSync.ts (new), server/routers.ts (add cron trigger route), server/netfile.ts (already exists — fetchNetFileTransactions). The admin UI already shows sync status.
4

ArcGIS District Boundary Integration

4 Sub-tasks

What: Replace the current database-based representative lookup with real GIS polygon queries for state assembly, state senate, congressional, and county supervisor districts. Add a map overlay showing district boundaries.

Effort: ~8-12 hours

Sub-task A: Query SD County GIS for State/Federal Districts

// ArcGIS FeatureServer endpoints for San Diego County:
// City Council Districts (already working):
// https://services2.arcgis.com/eJcVbjTyyZIzZ5Ye/arcgis/rest/services/CouncilDistricts/FeatureServer/0

// State Assembly Districts:
// https://services.arcgis.com/P3ePLMYs2RVChkJx/arcgis/rest/services/USA_118th_Congressional_Districts/FeatureServer/0

// State Senate Districts:
// https://services2.arcgis.com/[org]/arcgis/rest/services/CA_State_Senate_Districts/FeatureServer/0

// Congressional Districts:
// https://services.arcgis.com/P3ePLMYs2RVChkJx/arcgis/rest/services/USA_118th_Congressional_Districts/FeatureServer/0

// Query pattern (point-in-polygon):
const url = `${featureServerUrl}/query?` + new URLSearchParams({
  geometry: `${lng},${lat}`,
  geometryType: 'esriGeometryPoint',
  spatialRel: 'esriSpatialRelIntersects',
  outFields: '*',
  f: 'json',
});

Sub-task B: Replace LLM Inference with GIS Queries

// server/routers.ts — update address.lookup:
// Currently uses database lookup for sphere of influence
// Replace with real GIS queries:

async function queryDistrictFromGIS(lat: number, lng: number, featureServerUrl: string) {
  const res = await fetch(`${featureServerUrl}/query?` + new URLSearchParams({
    geometry: `${lng},${lat}`,
    geometryType: 'esriGeometryPoint',
    spatialRel: 'esriSpatialRelIntersects',
    outFields: '*',
    f: 'json',
  }));
  const data = await res.json();
  return data.features?.[0]?.attributes;
}

// Call for each level:
const [assembly, senate, congress, supervisor] = await Promise.all([
  queryDistrictFromGIS(lat, lng, ASSEMBLY_URL),
  queryDistrictFromGIS(lat, lng, SENATE_URL),
  queryDistrictFromGIS(lat, lng, CONGRESS_URL),
  queryDistrictFromGIS(lat, lng, SUPERVISOR_URL),
]);

Sub-task C: Cache District Boundary Data

// Option 1: In-memory cache (simple)
const districtCache = new Map<string, { data: any; expires: number }>();

function getCachedDistrict(key: string) {
  const cached = districtCache.get(key);
  if (cached && cached.expires > Date.now()) return cached.data;
  return null;
}

// Option 2: Database cache table
export const districtCache = mysqlTable('district_cache', {
  id: int('id').primaryKey().autoincrement(),
  lat: decimal('lat', { precision: 10, scale: 7 }),
  lng: decimal('lng', { precision: 10, scale: 7 }),
  districtType: varchar('district_type', { length: 50 }),
  districtData: json('district_data'),
  expiresAt: timestamp('expires_at'),
});

Sub-task D: Map Overlay with District Boundaries

// client/src/pages/AddressLookup.tsx
// Use the Google Maps component from client/src/components/Map.tsx
import { MapView } from '@/components/Map';

// In the results section, add a map:
<MapView
  onMapReady={(map, google) => {
    // Add district boundary polygons
    const districtLayer = new google.maps.Data();
    districtLayer.loadGeoJson(districtGeoJsonUrl);
    districtLayer.setStyle({
      fillColor: '#CF8A2E',
      fillOpacity: 0.15,
      strokeColor: '#CF8A2E',
      strokeWeight: 2,
    });
    districtLayer.setMap(map);
    
    // Add marker for searched address
    new google.maps.Marker({
      position: { lat, lng },
      map,
      title: 'Your Address',
    });
  }}
/>
Files to edit: server/routers.ts (address.lookup), client/src/pages/AddressLookup.tsx (map overlay), optionally drizzle/schema.ts (cache table). The ArcGIS endpoints are public — no API key needed.
5

Event RSVP Reminders & Auto-Summaries

Requires Email Service

What: Email/push reminders 1 day and 1 hour before events the user RSVP'd to. Auto-generate event summary after the event ends and link to related legislation and officer profiles.

Effort: ~4-6 hours

Sub-task A: Email Reminder Service

// Option 1: Use Manus notifyOwner for admin alerts (already works)
// Option 2: Add SendGrid/Resend for user emails:

// server/emailService.ts
import { Resend } from 'resend';
const resend = new Resend(process.env.RESEND_API_KEY);

export async function sendReminder(to: string, meeting: Meeting) {
  await resend.emails.send({
    from: 'ForACity <[email protected]>',
    to,
    subject: `Reminder: ${meeting.title} tomorrow`,
    html: `<h2>${meeting.title}</h2>
           <p>${meeting.location} at ${formatTime(meeting.date)}</p>
           <a href="https://foracity.com/meetings/${meeting.id}">View Details</a>`,
  });
}

// Env var needed: RESEND_API_KEY

Sub-task B: Scheduled Reminder Check

// server/jobs/meetingReminders.ts
export async function checkAndSendReminders() {
  const now = Date.now();
  const oneDayFromNow = now + 24 * 60 * 60 * 1000;
  const oneHourFromNow = now + 60 * 60 * 1000;
  
  // Find meetings happening in ~24h or ~1h
  const meetings = await getUpcomingMeetings();
  for (const meeting of meetings) {
    const timeUntil = meeting.date - now;
    if (timeUntil < oneDayFromNow && timeUntil > oneDayFromNow - 300000) {
      // Send 24h reminder to all RSVPs
      const rsvps = await getRsvpsForMeeting(meeting.id);
      for (const rsvp of rsvps) {
        await sendReminder(rsvp.user.email, meeting);
      }
    }
  }
}

Sub-task C: Auto-Generate Post-Event Summary

// After a meeting's date has passed, auto-generate summary:
// This already works via the AI meeting summary feature.
// Just need to trigger it automatically:

// In the scheduled job:
const pastMeetings = await getPastMeetingsWithoutSummary();
for (const meeting of pastMeetings) {
  if (!meeting.aiSummary && meeting.agendaItems) {
    // Trigger the existing generateSummary route
    await generateMeetingSummary(meeting.id);
    // This already auto-posts to feed (implemented)
  }
}
Files to edit: server/jobs/meetingReminders.ts (new), server/emailService.ts (new), server/routers.ts (add reminder trigger). RSVP UI already exists. Email service requires RESEND_API_KEY or similar.
6

Escondido Admin Subdomain

Requires Domain Setup

What: Route escondido.foracity.com to the admin portal. The admin dashboard already exists at /admin — this just needs subdomain routing and the domain added in Manus.

Effort: ~30 minutes

Step 1: Add Domain in Manus

# In Manus Management UI:
# Settings → Domains → Add Domain
# Enter: escondido.foracity.com
# Follow DNS instructions to point to Manus

Step 2: Add Subdomain Routing

// client/src/App.tsx — already has subdomain detection
// Just add escondido.foracity.com to the routing logic:

const hostname = window.location.hostname;
if (hostname === 'escondido.foracity.com') {
  // Auto-redirect to /admin
  return <Redirect to="/admin" />;
}
Files to edit: client/src/App.tsx (add subdomain detection for escondido). The admin dashboard is fully built at /admin with all CRUD, conflict detection, NetFile sync, meetings, and analytics.
7

Deferred / Low Priority

3 Items

County Board of Supervisors — Separate Area with County Branding

Create a dedicated section for SD County supervisors with county-specific colors and layout. Currently they appear in the jurisdiction switcher alongside cities. Low priority since the data is already accessible.

Files: client/src/pages/ (new CountyPortal.tsx), client/src/App.tsx (new route)

Mobile Viewport Testing (375px / 390px)

Systematic testing on iPhone SE (375px) and iPhone 14 (390px) viewports. The UI is already mobile-first with 44px touch targets and responsive grids. This is a QA pass, not a code change.

How: Chrome DevTools → Device Toolbar → iPhone SE / iPhone 14. Test all pages.

Scrape Voting Records from City Council Minutes

Parse PDF meeting minutes from the city website to extract individual council member votes on each agenda item. Currently voting records are seeded with sample data. This requires PDF parsing (pdf-parse npm package) and pattern matching.

Source: https://www.escondido.org/city-council-agendas-and-minutes → Download PDF minutes → Parse vote tables

Summary: All 21 Remaining Items

#ItemBlockerEffortPriority
1Web Push notification serviceVAPID keys4-6hHigh
2Register Facebook AppFB Developer Console1hMedium
3Configure FB OAuth redirectFB App created15mMedium
4Activate FB connect with real App IDFB App ID env var15mMedium
5Test FB flow end-to-endFB App live1hMedium
6Scheduled NetFile daily sync jobCron service3hHigh
7Auto-flag $500+ contributionsSync job running1hHigh
8Generate admin alerts for high-riskSync job running1hHigh
9Auto-create feed posts for contributionsSync job running1hMedium
10NetFile sync status indicatorNone (UI exists)30mLow
11Query GIS for state/federal districtsFind ArcGIS URLs3hMedium
12Replace DB lookup with GIS queriesGIS endpoints2hMedium
13Cache district boundary dataGIS working2hLow
14Map overlay with district boundariesGIS working3hMedium
15RSVP email/push remindersEmail service (Resend/SendGrid)3hMedium
16Auto-generate post-event summaryCron service2hMedium
17escondido.foracity.com subdomain routingDomain added in Manus15mHigh
18Add escondido.foracity.com domainDNS setup15mHigh
19County BoS separate branding areaNone (deferred)4hLow
20Mobile viewport testing (375/390px)None (QA pass)2hLow
21Scrape voting records from PDF minutesPDF parsing6hMedium

Action Required: Deploy Marketing & Kanban Subdomains

The marketing site and kanban board were built in Telegram but cannot be deployed from there. Each app needs its own Manus web project to bind custom domains and publish. The code is already in the GitHub monorepo — it just needs to be set up as standalone Manus projects.

Deployment Steps for Each Subdomain

marketing.foracity.comNot Deployed
1

Create a new Manus web project

Go to manus.im → New Task → Ask: "Create a new web project for the ForaCity marketing site"

2

Pull code from GitHub monorepo

# In the new Manus project sandbox:
git clone https://github.com/Renderlyapp/foracity.git temp
cp -r temp/marketing/* .
rm -rf temp
3

Install dependencies and verify it builds

pnpm install
pnpm build
4

Bind custom domain

Management UI → Settings → Domains → Add "marketing.foracity.com"

5

Save checkpoint and publish

Save a checkpoint → Click Publish in the Management UI header

CNAME (already set in GoDaddy): marketing → foracrm-6t6jggqx.manus.space

GitHub path: /marketing/ in Renderlyapp/foracity

Current Manus URL: https://foracrm-6t6jggqx.manus.space

projects.foracity.comNot Deployed
1

Create a new Manus web project

Go to manus.im → New Task → Ask: "Create a new web project for the ForaCity kanban board"

2

Pull code from GitHub monorepo

# In the new Manus project sandbox:
git clone https://github.com/Renderlyapp/foracity.git temp
cp -r temp/projects/* .
rm -rf temp
3

Install dependencies, push DB schema, and verify

pnpm install
pnpm db:push
pnpm build
4

Bind custom domain

Management UI → Settings → Domains → Add "projects.foracity.com"

5

Save checkpoint and publish

Save a checkpoint → Click Publish in the Management UI header

CNAME (already set in GoDaddy): projects → foracitykanban-8p8cpdcz.manus.space

GitHub path: /projects/ in Renderlyapp/foracity

Current Manus URL: https://foracitykanban-8p8cpdcz.manus.space

legal.foracity.comDeployed

This project (levine-act-guide) is already deployed and bound to legal.foracity.com. No action needed.

CNAME: legal → foracomply-3v8zasyd.manus.space

Status: Live and published

GoDaddy DNS Reference

These CNAME records should already be set in GoDaddy DNS for foracity.com:

TypeNameValue
CNAMEmarketingforacrm-6t6jggqx.manus.space
CNAMElegalforacomply-3v8zasyd.manus.space
CNAMEprojectsforacitykanban-8p8cpdcz.manus.space

Important: Make sure GoDaddy proxy is OFF (DNS only) for SSL to work with Manus hosting.

Why Can't We Deploy from Telegram?

Manus projects built via Telegram don't surface in the web dashboard's Management UI, which is the only place where custom domains can be bound and projects can be published. Each subdomain needs its own Manus web project with its own hosting instance. The workaround is to create new web projects, pull the code from the GitHub monorepo, and deploy from the web UI. This has been reported as a bug to the Manus team.

ForACity Developer Portal — For Robert Dacher and the engineering team.
Questions? Check the README.md and DEVELOPER_SETUP.md in the repo root.