- Introduction
- Pitch
- Hogwarts
- Live Demo
- MVP
- Roadmap
- Launch Sprint
- PRD
- Get Started
- Localhost
- Architecture
- Structure
- Pattern
- Page
- Layout
- Content
- Types
- Config
- Actions
- Queries
- Authorization
- Validation
- Form
- Table
- Detail
- Card
- Util
- Hooks
- List Params
- Views
- README.md
- ISSUE.md
- Technology Stack
- Database
- File
- CDN Assets
- Entry Points
- Dashboard
- Authentication
- Credentials
- OAuth
- Flow Diagrams
- Multi-Tenancy
- Offline
- Onboarding
- Onboarding Videos
- Add Values
- Admission
- Application
- Attendance
- Compliance
- Profile
- Exams
- Exam Wizard
- Timetable
- Classrooms
- Notifications
- Conference
- LMS (Lumos)
- Finance
- Fee Management
- Invoice
- Wallet
- Salary
- Payroll
- Timesheet
- Expenses
- Budget
- Receipt
- Accounts
- Banking
- Reports
- Dashboard
- Permissions
- Messages
- Integration Flow
- Provision
- AI Document Processing
- Document Intelligence
- Internationalization
- Translation
- Translation Guide
- Icons
- Docs Factory
- Inspiration
- Listings
- Teachers
- Students
- Catalog
- Library
- Contributing
- Code of conduct
- GitHub Workflow
- Database Seeds
- Database Safety
- Test Accounts
- Playwright
- Prettier
- Block Rebound
Every school user (student, teacher, parent, staff) gets a GitHub-style profile: a left sidebar (avatar, name, real stat counts, earned badges, organizations) and right content (role tabs, a real contribution graph, pinned items, and an activity feed). One optional catch-all route handles both own-profile and view-by-id.
Everything on the page is real and tenant-scoped. The block was rebuilt (2026-06-15) to remove the previous prototype's fabricated data — hardcoded stats,
Math.random()activity, mock "Student of the Year" achievements, and "Chess Club" organizations. When a real source is missing, an honest empty state is shown.
Routes
| Route | Page | Behaviour |
|---|---|---|
/profile | profile/[[...id]]/page.tsx | Own profile (isOwner=true) |
/profile/<id> | profile/[[...id]]/page.tsx | View by id (User, or wizard entity) |
Path: src/app/[lang]/s/[subdomain]/(school-dashboard)/profile/[[...id]]/. The
route has loading.tsx (layout-matched skeleton) and error.tsx (locale-aware
boundary). id may be a User id, or a wizard-created Student/Teacher id
that has no User row (handled by a fallback path).
Architecture
The page calls a single typed read and passes the result down to pure presenter components — no client component fetches or fabricates data.
page.tsx ──> getProfileView(targetId, lang) // queries.ts (typed, permission-masked)
│ └─ real stats · badges · organizations · pinned · activity · roleDetail
└─> ProfileDetailContent ──> ProfileContent (client.tsx)
├─ ProfileSidebar (avatar, stats, badges, organizations, edit entry)
├─ PinnedItems (real pins; owner reorder/remove, persisted)
├─ ContributionGraph(SWR → getContributionData; empty grid when none)
├─ ContributionActivity (real UserActivity grouped by month)
├─ AchievementsGrid (achievements tab: earned-badge grid)
└─ Student/Teacher/Parent/Staff dashboards (real subjects/classes/children/orgs)
queries.ts is intentionally not "use server" so callers can wrap it in
cache(). Client-callable mutations live in actions.ts ("use server").
Data model
| Model | Table | Purpose |
|---|---|---|
UserActivity | user_activities | Activity feed + contribution graph |
PinnedItem | pinned_items | Up to 6 pinned items per user |
ProfileBadge | profile_badges | Earned badges (awarded by the engine) |
Organization | organizations | School clubs / committees / teams |
OrganizationMembership | organization_memberships | User ↔ organization, with role |
Achievement (existing) | achievements | Student achievement records → badges |
GitHub-style fields (bio, website, timezone, pronouns, statusEmoji,
statusMessage, socialLinks) live on User.
Deploy note: the migration-of-record
prisma/migrations/20260615000000_add_profile_badges_organizations/was applied to prod on 2026-06-15. To populate the demo school's profile surfaces runpnpm db:seed:single profile-extrasthenpnpm db:seed:single profile-activity.
Demo data (profile-activity seed)
Every profile section renders from real rows, so the demo needs real rows. The
idempotent profile-activity seed (part of seedMain) creates, for the demo
school: current-year attendance for the demo student's section (marked by the
demo teacher — lights both graphs and earns the attendance badges), an
Arabic activity feed for demo accounts + roster/teacher/guardian users,
role-appropriate pinned items derived from real subjects/classes/children/
organizations, a parent↔teacher conversation whose parent-sent messages light
the parent graph, expense approvals spread across admin/staff/accountant (staff
graph), demo-student achievements in distinct categories, and the GitHub-style
User fields (status, website, social links, timezone). Badge artwork is served
from the CDN at hogwarts/<icon>.png (sources in /public/github, re-upload
with npx tsx prisma/scripts/upload-badge-art.ts).
Badge earning engine
badges.ts → recomputeProfileBadges(userId, schoolId, role) derives badges from
real signals and reconciles the user's auto-managed badge rows idempotently:
perfect_attendance— student with zero absences this year (or a teacher who marked attendance consistently)top_of_class/honor_roll— student merit rank 1 / top 3diligent_educator— teacher who graded/marked ≥ 20 items this yearactive_contributor— ≥ 20 logged activities this yearcommunity_member— belongs to ≥ 1 organization- a student's own
Achievementrecords → one badge each (icon by category, level by scope)
Manually-awarded badges (keys outside the catalog / achievement_ prefix) are
never touched. Trigger a refresh with the recomputeMyBadges server action.
Permissions (cross-tenant-safe)
getPermissionLevel (detail/permissions.ts) returns
OWNER | ADMIN | STAFF | RELATED | PUBLIC. Elevation rules:
- DEVELOPER is the only role with cross-school access.
- ADMIN / TEACHER / STAFF / ACCOUNTANT are elevated only when
viewerSchoolId === profileSchoolId— otherwisePUBLIC. - Same-school students/guardians get
RELATED.
getProfileView masks sensitive fields (email, ids) for non-privileged viewers
on top of the always-tenant-scoped DB query.
Server actions (actions.ts)
| Action | Purpose |
|---|---|
updateGitHubProfile(input) | Persist name/bio/website/timezone/pronouns/status/social |
uploadProfileAvatar(formData) | Avatar → User.image and role profilePhotoUrl |
getPinnedItems(userId?) | Read pins (public-only for other users) |
updatePinnedItems(items) | Replace own pins (max 6) in a transaction |
getContributionData(params) | Heatmap data (role lookup schoolId-scoped) |
getRecentActivity(userId?, limit) | Activity feed (viewer-permission gated) |
logUserActivity(input) | Append a UserActivity row |
recomputeMyBadges() | Re-run the earning engine for the caller |
Reads are in queries.ts (getProfileView); self-edit entity data in
edit-role-actions.ts (getOwnEntity).
Real data, no mock
Every surface is DB-backed:
- Stats — counts of the user's subjects / classes / students / children
- Activity — real
UserActivityrows (noMath.random()) - Badges — earned via the engine from real attendance/results/merit
- Organizations — real memberships (+ a teacher's primary department)
- Pinned items — real
PinnedItemrows; empty state when none
Known issues
- DB tables deploy-pending (see Deploy note).
- Adding a new pinned item from the UI is not yet wired (display + reorder + remove are).
profile-flows.spec.tspredates the rebuild — selectors + a cross-school isolation case are a follow-up.
See also
- Tests:
src/tests/school-dashboard/profile/ - Block records:
src/components/school-dashboard/profile/{README,ISSUE,CLAUDE}.md