Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
9b624c3
feat(website): add Clean Air Forum selfie wall display and temporary API
2phonebabykeem Jul 7, 2026
6a68628
feat(website): redesign selfie wall on brand, harden submission API
2phonebabykeem Jul 7, 2026
c4955fc
docs: list follow-ups needed from backend/Cloudinary/DevOps teams
2phonebabykeem Jul 7, 2026
ba46bcf
docs: confirm event details, fix PR references after split
2phonebabykeem Jul 7, 2026
9eb6980
Merge branch 'staging' into feature/clean-air-forum-selfie-filter-web…
OchiengPaul442 Jul 7, 2026
60574c1
refactor: remove Clean Air Forum selfies API and related components
OchiengPaul442 Jul 7, 2026
7a37228
refactor: move FacesOfCleanAirPage to features directory and update i…
OchiengPaul442 Jul 7, 2026
ced6691
docs: update CLAUDE.md to include repository structure, tech stack, a…
OchiengPaul442 Jul 7, 2026
a8816fc
refactor: enhance FacesOfCleanAirPage with improved state management …
OchiengPaul442 Jul 7, 2026
b9d64f0
style: update background gradient for FacesOfCleanAirPage
OchiengPaul442 Jul 7, 2026
50df4ce
refactor: update metadata generation for FacesOfCleanAirPage and remo…
OchiengPaul442 Jul 7, 2026
12f6a4c
refactor: streamline metadata generation for FacesOfCleanAirPage and …
OchiengPaul442 Jul 7, 2026
86f1db8
refactor: update text content and enhance animations across various p…
OchiengPaul442 Jul 8, 2026
75e2b13
style: enhance FaceCard component with improved logo size and text st…
OchiengPaul442 Jul 8, 2026
de5bb7a
Implement code changes to enhance functionality and improve performance
OchiengPaul442 Jul 8, 2026
9e15d23
refactor: optimize animations and improve visual effects on FacesOfCl…
OchiengPaul442 Jul 8, 2026
fd3f5c4
refactor: enhance FacesOfCleanAirPage with responsive pagination and …
OchiengPaul442 Jul 8, 2026
5b448fe
refactor: remove apply button and update application status on AirQoD…
OchiengPaul442 Jul 8, 2026
bfb336b
style: update README.md with additional badges for Node.js and TypeSc…
OchiengPaul442 Jul 8, 2026
ea7a8d2
feat: implement polling with visibility for improved data fetching an…
OchiengPaul442 Jul 8, 2026
0692f8f
refactor: update card display settings and enhance layout responsiven…
OchiengPaul442 Jul 8, 2026
ce8b3a2
refactor: update polling interval to 45 seconds and implement exponen…
OchiengPaul442 Jul 8, 2026
8e16ceb
feat: enhance ErrorBoundary for silent reload on Google Translate err…
OchiengPaul442 Jul 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 76 additions & 42 deletions .github/workflows/website-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -133,60 +133,95 @@ jobs:
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea
with:
script: |
const fs = require('fs');
const steps = [
{ name: 'Checkout', id: 'checkout', emoji: '📦' },
{ name: 'Setup Node', id: 'setup-node', emoji: '⚙️' },
{ name: 'Install dependencies', id: 'install', emoji: '📥' },
{ name: 'Lint', id: 'lint', emoji: '🔍' },
{ name: 'Type Check', id: 'typecheck', emoji: '🔎' },
{ name: 'Unit Tests & Coverage', id: 'tests', emoji: '🧪' }
const stepMeta = [
{ name: 'Checkout', id: 'checkout', emoji: '📦', fix: null },
{ name: 'Setup Node', id: 'setup-node', emoji: '⚙️', fix: null },
{ name: 'Install dependencies', id: 'install', emoji: '📥', fix: '`rm -rf node_modules && npm ci`' },
{ name: 'Lint', id: 'lint', emoji: '🔍', fix: '`npm run lint:fix`' },
{ name: 'Type Check', id: 'typecheck', emoji: '🔎', fix: 'Fix TypeScript errors listed below' },
{ name: 'Unit Tests & Coverage', id: 'tests', emoji: '🧪', fix: '`npm run test:coverage` locally' }
];

// Fetch jobs via API (workflow_run payload doesn't include step outcomes)
const { data: jobsData } = await github.rest.actions.listJobsForWorkflowRun({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: context.runId,
per_page: 100
});

const failedSteps = [];
for (const step of steps) {
const outcome = context.payload.workflow_run?.jobs?.[0]?.steps?.find(
s => s.name === step.name
)?.outcome;
if (outcome === 'failed') {
failedSteps.push(step);
const failedLogs = [];

for (const job of jobsData.jobs || []) {
for (const step of job.steps || []) {
if (step.conclusion === 'failure') {
const meta = stepMeta.find(s => s.name === step.name);
if (meta) {
failedSteps.push({ ...meta, apiName: step.name });
} else {
failedSteps.push({ name: step.name, id: 'unknown', emoji: '❌', fix: null, apiName: step.name });
}

// Attempt to fetch the step log for context
try {
const { data: logs } = await github.rest.actions.downloadJobLogsForWorkflowRun({
owner: context.repo.owner,
repo: context.repo.repo,
job_id: job.id
});
if (logs) {
failedLogs.push({ step: step.name, logUrl: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}/job/${job.id}` });
}
} catch (e) {
// Logs may not be available; continue without them
}
}
}
}

// Fallback: if we couldn't determine exact step, assume tests or lint
// Fallback when step detection fails
if (failedSteps.length === 0) {
failedSteps.push({ name: 'Unknown step', id: 'unknown', emoji: '❌' });
failedSteps.push({ name: 'Build', id: 'build', emoji: '❌', fix: 'Check the workflow logs for details' });
}

const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
const failedList = failedSteps.map(s => `${s.emoji} **${s.name}**`).join('\n');

const body = [
'## ❌ Website CI Failed',
'',
`**Failed step(s):**`,
failedList,
'',
'### Next Steps',
'',
];

if (failedSteps.some(s => s.id === 'lint')) {
body.push('- Run `npm run lint:fix` to auto-fix lint issues');
}
if (failedSteps.some(s => s.id === 'typecheck')) {
body.push('- Check TypeScript errors above and fix type issues');
// Build the comment body
const body = [];
body.push('## ❌ Website CI Failed');
body.push('');

// Failed steps summary
body.push('**Failed step(s):**');
body.push('');
for (const s of failedSteps) {
body.push(`- ${s.emoji} **${s.name}**`);
}
if (failedSteps.some(s => s.id === 'tests')) {
body.push('- Run `npm run test:coverage` locally to see test failures');
body.push('');
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Actionable next steps
body.push('### How to Fix');
body.push('');
for (const s of failedSteps) {
if (s.fix) {
body.push(`- **${s.name}:** ${s.fix}`);
}
}
if (failedSteps.some(s => s.id === 'install')) {
body.push('- Check for dependency issues, try `rm -rf node_modules && npm install`');
body.push('');

// Links
body.push('### Resources');
body.push('');
body.push(`- [View full workflow run](${runUrl})`);
if (failedLogs.length > 0) {
for (const log of failedLogs) {
body.push(`- [View logs for \`${log.step}\`](${log.logUrl})`);
}
}

body.push('', `🔗 [View workflow run](${runUrl})`);
body.push('', '---');
body.push('*Please fix the issues and push again.*');
body.push('');
body.push('---');
body.push('*Please fix the issues and push again to re-trigger CI.*');

// Find and update existing comment or create new one
const marker = '## ❌ Website CI Failed';
Expand All @@ -196,8 +231,7 @@ jobs:
issue_number: context.issue.number,
per_page: 100
});

const existing = comments.find(c => c.body.includes(marker));
const existing = comments.find(c => c.body && c.body.includes(marker));

if (existing) {
await github.rest.issues.updateComment({
Expand Down
119 changes: 119 additions & 0 deletions docs/clean-air-forum-followups.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# Clean Air Forum selfie feature — follow-ups for other teams

This feature (mobile selfie filter/sticker + `/selfies` conference wall
display) was built as a **temporary, fully client-side-and-mock-API
implementation** so it could be demoed end-to-end before any backend or
infra work existed for it. Everything below is scaffolding, explicitly
marked `TEMPORARY` in code, and needs real ownership before/soon after the
event it launches with.

Related PRs: [#3755](https://github.com/airqo-platform/AirQo-frontend/pull/3755) (mobile), [#3756](https://github.com/airqo-platform/AirQo-frontend/pull/3756) (website)

## 1. Backend team — replace the mock submissions API

**What exists today**: `src/website/src/services/cleanAirForumSelfiesStore.ts`
is a plain in-memory array behind `src/website/src/app/api/clean-air-forum/selfies/`
(`GET`/`POST`) and `.../selfies/[id]/` (`PATCH` to hide). It:

- Resets on every redeploy and isn't shared across replicas — a submission
can vanish mid-event if the website pod restarts or scales out.
- Has no real auth beyond a shared secret/PIN (see §3) — fine for a
short-lived event, not something to keep long-term.

**What's needed**: equivalent endpoints on the real AirQo backend, with
persistent storage (even a simple table is enough — submissions are
small: image URL + AQI metadata + timestamp + hidden flag). The
request/response shapes in the mock were deliberately kept simple so they
can carry over almost unchanged:

- `POST /selfies` — body `{ eventId, imageUrl, locationName?, pm25Value?, aqiCategory?, displayName? }` → `201 { submission }`
- `GET /selfies?eventId=...` — → `200 { submissions: [...] }`, newest first, hidden ones excluded
- `PATCH /selfies/:id` — hides (soft-deletes) a submission, used by wall moderation

Once these exist, the two Next.js routes above should just proxy to them
(or be deleted in favor of calling the real API directly from
`SelfiesWallPage.tsx` and the mobile app).

## 2. Cloudinary / whoever owns that account — harden the upload preset

Selfies are uploaded directly from the mobile app to Cloudinary using an
**unsigned upload preset** (`NEXT_PUBLIC_CLOUDINARY_NAME` /
`NEXT_PUBLIC_CLOUDINARY_PRESET`, read in
`src/mobile/lib/src/app/shared/services/clean_air_forum_submission_service.dart`).
Unsigned presets are bundled in the app binary by design (that's how
unsigned uploads work), so anyone who decompiles the app can extract the
preset name and cloud name and upload directly to this Cloudinary account.

Please apply, at minimum, on the `clean_air_forum_selfies` preset:

- **Folder lock** — restrict the preset to only write into
`clean_air_forum_selfies/` (prevents overwriting/polluting other
folders in the account).
- **File size / format limits** — cap to a few MB, images only.
- **Moderation** — consider enabling Cloudinary's built-in moderation
add-on (or at least manual review capability) given this feeds a public
venue display.
- Longer-term: move to a **signed upload** flow (mobile app calls our own
backend for a signature, backend calls Cloudinary) once the real backend
from §1 exists — this closes the preset-exposure issue completely
instead of just mitigating it.

The website route already validates that submitted `imageUrl`s are
`https://res.cloudinary.com/.../clean_air_forum_selfies/...` before
accepting them (defense in depth), but that alone doesn't stop someone
with the preset from uploading directly to Cloudinary — it only stops them
from getting arbitrary *other* URLs onto the wall via our API.

## 3. DevOps / whoever manages GitHub Actions secrets — wire up new env vars

Two new secrets need to be created and referenced in the deploy workflows
(`.github/workflows/deploy-frontends-to-staging.yml`,
`deploy-frontends-to-production.yml`, the Azure equivalents, and
`deploy-frontend-pr-previews.yml` if selfie testing on PR previews matters).
These are **server-only** vars (no `NEXT_PUBLIC_` prefix), so they only need
adding to each workflow's `.env.yaml` generation step (same pattern as
`SLACK_WEBHOOK_URL`) — no Docker `--build-arg` needed.

| Env var | Where it's used | What it does |
|---|---|---|
| `CLEAN_AIR_FORUM_WALL_PIN` | `src/website/src/app/api/clean-air-forum/selfies/[id]/route.ts` | Staff PIN required to remove a photo from the wall via double-tap/long-press |
| `CLEAN_AIR_FORUM_SUBMISSION_SECRET` | `src/website/src/app/api/clean-air-forum/selfies/route.ts` | Shared secret the mobile app sends (`x-clean-air-forum-secret` header) so only the app can post to the wall |

**Important**: both checks now **fail closed in production** if the
corresponding env var is missing (they only fail open in
development/preview, for local testing convenience) — see
`src/website/src/services/cleanAirForumAuth.ts`. If these aren't
configured before a production deploy, moderation and/or submissions will
be rejected outright (with a `console.warn` in the server logs), not
silently left open. **Please set both before the forum goes live in
production.**

The mobile app also needs a matching secret in its own env
(`CLEAN_AIR_FORUM_API_SECRET` in `src/mobile/.env.prod` /
`.env.dev` — not committed to the repo) equal to whatever
`CLEAN_AIR_FORUM_SUBMISSION_SECRET` is set to on the website side.

## 4. Rate limiting — needs a real (distributed) solution

`route.ts` currently has a best-effort **in-memory, per-server-instance**
rate limit (5 submissions/IP/minute) as a stopgap — it resets per instance
and doesn't hold across replicas, so it only blunts casual spam, not a
determined abuser hitting a load-balanced deployment. If abuse becomes a
real concern for the event, this needs a proper distributed rate limiter
(e.g. Cloudflare in front of the site, or a Redis/Upstash-backed limiter)
— that's an infra decision beyond what this PR can set up.

## 5. Content moderation policy

Right now, moderation is manual only: a staff member double-taps or
long-presses a bad photo on the wall display itself and enters a shared
PIN. There's no automated image moderation (nudity/violence detection,
etc.). Given this is a public-facing conference wall, it may be worth
asking whoever owns Cloudinary to enable an automated moderation add-on
(see §2) as a first line of defense, with manual removal as the backstop.

## Event details

`src/mobile/lib/src/app/dashboard/utils/clean_air_forum_branding.dart`
has `edition = 'Pretoria 2026'` and `dateRange = '13TH-16TH JULY'` — both
confirmed correct as of 2026-07-07. No action needed here.
Loading
Loading