Designing a Private, Cross-Cohort Community Platform, Part 1
Work of Service · July 2026
K1 graduated into a real institutional gap, with no established infrastructure or settled precedent for what the cohort even was yet. Underneath the platform, this case study is really about the leadership calls that come with building for a still-forming community — who gets access, what counts as consent, and how much structure the moment actually called for.
QE Rationale
This project counts toward Fundamentals of Educational Technology Leadership: it's applied leadership, not just technical output. The QE portfolio rubric imposes a fixed, non-negotiable structure — an introduction page, three domain sections in a specific order, a Professional Growth Plan — on every K1 member regardless of their technical background. Left alone, that gap between "here's the rubric" and "here's a working website" would have fallen hardest on the classmates least equipped to close it themselves. Closing that gap for the whole cohort, unprompted, unpaid, and in a way designed to outlast my own involvement in it, is the leadership work this domain asks candidates to demonstrate — not just that I could build the tool, but that I chose to build it as shared infrastructure rather than a personal shortcut.
Role: Product Owner / UX Designer
Timeline: Summer 2026, built during and immediately following the K1 Summer Institute
The Problem
Three needs surfaced almost immediately once I started scoping this with my cohort:
- Privacy at odds with usefulness. A cohort directory is only useful if it holds real contact information — but that same information is exactly what needs the most protection. Any solution had to treat privacy as a design constraint from the first architectural decision, not a feature bolted on later.
- Sustainability without centralization. I didn't want to become the sole permanent maintainer of 19 (and eventually many more) people's personal information. The system needed to distribute ownership without opening the door to people overwriting each other's data.
- An identity in flux. K1 sits at an unusual seam — no longer NJCU, not fully defined by "Kean" either, and mindful that older cohorts (C1–C13) never went through this transition at all. Every naming and framing decision (is this an "alumni" network? Is K1 "graduated"? Is the platform K1's or the program's?) had real stakes for how welcome or alienating the tool would feel to different cohorts.
Design Process
Starting with data modeling as a UX exercise
Before any interface existed, I worked through what a member record, a course, an assignment, and a calendar event actually needed to be — and where they related to each other. Treating this as an information-architecture problem first (rather than jumping to screens) meant decisions like "should the academic calendar be manually maintained or derived from assignment due dates" got resolved structurally, rather than becoming ongoing manual busywork later.
Access control as the primary design material
The most consequential UX decisions in this project weren't visual — they were about who sees what, and when. Early on, I considered a shared password (the same simple model I'd used for my own doctoral portfolio site) for the whole directory. Testing that assumption against the actual data — phone numbers, personal emails — surfaced a clear risk: a single shared secret gates a door, but it doesn't distinguish between the people walking through it. I moved to a passwordless, individually-verified model instead: each person's identity is tied to their own record, so editing rights, visibility, and eventually cross-cohort access could all be scoped to who someone actually is, not just whether they knew a password.
This shift also solved the second problem above — instead of one administrator maintaining everyone's data, each member can edit only their own entry, with a confirmation step before any change goes live. Ownership is distributed by design, not by trust alone.
Designing for the seams, not just the happy path
A recurring theme was catching the moments where the system's assumptions didn't match reality:
- A public onboarding form for future cohorts initially had no safeguard against my own cohort submitting duplicate entries — solved with real-time validation that recognizes "K1" the moment it's entered and redirects people to the sign-in they already have, before they waste effort on a form they don't need.
- A raw database error (a duplicate-email constraint violation) surfaced directly to end users — a reminder that technical correctness and user-facing clarity are two different jobs, and closing the gap between them required explicit, status-aware messaging rather than a generic error.
- Link fields without a protocol prefix silently broke instead of erroring, sending people to a broken internal URL instead of an external site — an example of how a small, invisible input validation gap can produce a confusing experience with no error message at all.
- Tightening one security gap quietly opened another. To stop mistyped or guessed emails from generating stray authentication accounts, I disabled automatic account creation on sign-in. That closed the door on typos — but it also meant any legitimate member who simply hadn't logged in yet had no way in either, since their very first sign-in attempt was previously what created their account in the first place. The fix was a one-time backfill: proactively provisioning accounts for everyone who already belonged in the system, rather than continuing to rely on first login to do that setup. It was a clear reminder that closing a security gap and testing the legitimate first-time-user path are two separate steps, not one.
Naming as an inclusion decision
Perhaps the most "UX-as-empathy" decision in this project was around language. I initially modeled cross-cohort connections as an "alumni" feature — accurate for C1–C11, but (as of this writing) not for C12, 13 & K1, which hasn't graduated. Renaming it to something cohort-neutral, and explicitly designing the cohort field to accept any future cohort code (rather than a hardcoded, ever-changing dropdown list), reflected a broader principle: the system should describe the community as it actually is, not force the community to fit the system's original assumptions.
Scaling data entry without scaling risk
Bringing C12 and C13 into the platform — the first bulk import of members who didn't build the system themselves — surfaced a different category of problem than the original K1 build: data quality at someone else's hands, arriving in bulk rather than one signup at a time.
- Bios varied wildly in length and structure, some spanning five loosely-organized paragraphs. Rather than leaving inconsistency for the UI to absorb, I condensed outlier bios to a single paragraph before import, while leaving already-concise entries and quote-only submissions untouched — editing toward consistency without erasing the difference between a full bio and a short reflection.
- Photo import failed almost entirely: the spreadsheet's image URLs were Google Sites CDN links carrying short-lived signed tokens, expired by the time of import regardless of how recently the sheet had been exported. Automated re-scraping of the live portfolio pages didn't solve it either — Google Sites doesn't semantically distinguish a headshot from a decorative graphic, so a scraper has no reliable way to pick the right image. The durable fix isn't a smarter scraper; it's removing the dependency on a third-party CDN entirely by either collecting headshots directly from members, or manually taking screenshots and labeling them accordingly going forward.
- One member's import was blocked entirely by a missing required field (email, the unique key the whole auth model depends on) — a reminder that a bulk import is only as complete as its least-complete row, and that surfacing which row and why mattered more than treating the batch as a single pass/fail unit.
A second import wave: reconstructing structure from unstructured legacy data
C12 and C13 arrived as a spreadsheet with real fields already filled in — messy, but recognizably a directory. C1–C11 arrived as something closer to an archive than a dataset: eleven separate historical rosters, most with nothing but a full name per person, no split between first and last name, no email, no consistent column layout from one cohort's sheet to the next. A few cohorts (C6, C3) had richer legacy data — real bios, real institutional or personal emails, pulled from an old program bio page — but the majority were bare name lists.
This meant the import itself became a small design problem rather than a mechanical one:
- Name splitting as a rule, not a lookup. With no structured first/last name data to draw from, the only defensible approach was a consistent, stated rule — last word of a full name becomes the last name, everything remaining becomes the first name — applied uniformly rather than judgment-called per person. That rule handles the common case cleanly but produces a few odd literal results on multi-word names (a middle initial folded into what displays as a "first name," a parenthetical nickname landing in the same field) that were flagged explicitly rather than silently smoothed over. Making the rule visible, including where it produces an odd result, mattered more than making every individual row look tidy.
- Placeholder emails as a labeled hypothesis, not a fact. Since C1–C11 predate any system that captured real contact info, the only way to fill an email field that downstream auth logic depends on was to generate one from a known institutional pattern (
[first initial][last name]@njcu.edu). Generating the value was the easy part; the harder part was making sure it never got treated as verified data — which meant surfacing it to Lovable explicitly as a placeholder needing its own visibility rules, not just a filled-in field indistinguishable from a real one. - Structural normalization before content. Because each legacy cohort sheet had evolved its own slightly different column layout, the eleven sheets had to be normalized to one shared structure before any of the above could be applied consistently. Fixing the shape of the data was a prerequisite to fixing its content, not a separate afterthought.
The throughline connecting this to the C12/C13 import: both were cases where "just import the spreadsheet" undersold the actual work, which was reconstructing enough structure and labeling enough uncertainty that the imported data wouldn't quietly masquerade as equivalent to a real signup.
Consistency as its own design problem
As more fields accumulated per member — P1 Group, phone, a "special date," an "other social" field, bios of wildly different lengths — the card UI that had looked clean for 19 K1 members started to show real strain once it needed to represent 200+ people across a decade of cohorts with inconsistent data.
- The fix wasn't a single visual tweak but a component boundary: contact fields (email, portfolio, LinkedIn, other social, eventually phone) were consolidated into one shared
ContactLinkscomponent, rendering only the fields a given member actually has, as a single row of short labeled links rather than sprawling label-value pairs. Bio display followed the same pattern — a shared truncate/expand component rather than one bio-handling per card type. - That consolidation had to be earned the hard way: the first two rounds of "fix the card," applied without checking where else member cards render, landed on one surface (the K1 directory) and silently skipped others (the cross-cohort network view) that displayed the same data through a different component. The recurring lesson — that a fix scoped to a card is not a fix scoped to the data — turned "list every surface this touches, then confirm which already got the change" into a standing step of every subsequent request, not just a one-off audit.
- The same consolidation instinct went one step further into the fields themselves, not just the component rendering them:
linkedin,portfolio_link, andother_socialbecame the single source of truth for a member's outbound links, replacing a set of columns that had quietly drifted out of sync — duplicatelinkedin_urlandwebsite_urlfields sitting alongside the canonical ones, and ten LinkedIn values stored as Google's redirect-tracking wrapper links instead of the plain profile URL. Unwrapping those, dropping the duplicate columns, and filteringother_socialat render time by URL validity meant the ContactLinks component above didn't just render consistently — it could also trust what it was being handed. - Some fields earned selective visibility rather than uniform treatment. P1 Group — a cohort's group-project site, and for some members the only real proof they filled out the original form — stays visible on the internal review/verification screen but is now hidden from the public-facing card, since it serves an admin purpose the directory's audience doesn't need. Phone numbers moved from visible digits to a
tel:labeled link, extending the platform's privacy-by-default posture to a data type the original access-control work hadn't yet addressed. - Not every problem got solved immediately, and that was itself a decision. Older cohorts (pre-C12) never went through the Kean transition and likely weren't issued the institutional emails younger cohorts have, so an enrollment-verification shortcut used for C12/C13 wouldn't transfer. Rather than guess at a verification flow for a population with no real submissions yet, the plan is to design a fallback path — alternate proof-of-enrollment fields, surfaced to reviewers as a flag rather than a hard block — but hold off building it until an actual pre-C12 member hits the join form. Designing for a hypothetical edge case can be as risky as ignoring one; sometimes the right move is to wait for a real user before writing the rule.
When "done" isn't done: scope drift between instruction and implementation
Once C1–C11 were in the system carrying placeholder data, a new category of bug appeared — not data-quality problems, but a gap between what I asked an AI dev tool to change and what it actually changed. None of these were catastrophic, but each one taught something about how to specify a fix precisely enough that "done" and "done correctly" mean the same thing:
- Hiding a value is not the same instruction as removing its input. I asked to hide the (placeholder, potentially wrong) emails from public display for unclaimed legacy members. The fix hid the email everywhere, including the field a member would need to enter their real email when claiming their own card — collapsing "don't show this to the public" and "don't let anyone touch this" into one action when only the first was intended. The correction had to spell out the distinction explicitly: display and edit-access are two different gates, and a request about one should never implicitly resolve the other.
- A default value can quietly misrepresent an event that never happened. The bulk import wrote the same import timestamp into a
terms_accepted_atcolumn for every legacy record — a field meant to mark the moment a real person actually agreed to the platform's terms through the join flow. Left alone, that default would have made 138 people who've never seen the platform indistinguishable, in the data, from people who had actually signed up and consented. The fix wasn't just clearing the bad values; it was making sure the column could only ever be written by the one code path (/join) where the event it represents actually occurs — so the same mistake can't quietly recur on the next bulk import. - A "swap this field for that one" request can partially land. Asking to replace a P1-group-site input with a personal-email input in the self-edit form resulted in the email field being added without the P1 field being removed — a literal reading of "add this" without the paired "and take that away." The lesson mirrored the card-consistency problem above: a compound instruction needs to be checked against all of its parts once implemented, not just the part that's easiest to verify at a glance.
None of these three were security-critical in the way the RLS/WITH CHECK and avatar-path gaps below were — but they're the same underlying failure mode at a smaller scale: a system built through natural-language instructions is only as precise as the instruction, and "that's basically what I meant" is exactly the gap where these bugs live. The practical takeaway carried into every later request: state the boundary explicitly (display vs. edit access, a value's source vs. its default, an addition vs. its paired removal) rather than trusting it to be inferred correctly.
Closing the gap between "access-controlled" and "actually enforced"
An automated security review of the schema surfaced two gaps between the access model as designed and as implemented — a useful reminder that RLS policies checking identity don't automatically restrict what that identity can change.
- The members table's self-update policy verified the caller was editing their own row, but didn't restrict which columns they could set — meaning a member could change their own cohort value and, since read access across the platform derives from that same field, grant themselves visibility into another cohort's directory, courses, and calendar. Because RLS's
WITH CHECKcan't compare old and new values in a single expression, the fix required a trigger rather than a policy tweak: editors bypass it entirely, while self-edits are blocked specifically on cohort, email, and a couple of ordering fields, with every genuinely self-editable field (bio, phone, links, avatar) left open. - A parallel gap existed in avatar storage: the upload policy checked which bucket a file was going into, but not whether the uploader owned the path they were writing to — unlike the corresponding update/delete policies, which did. The fix split uploads into two ownership-scoped paths matching the app's two real upload flows (a signed-in member's own avatar, and a new applicant's not-yet-approved submission), rather than reusing a single broad rule for both.
Neither gap had been exploited — this was a proactive audit, not an incident — but both were the kind of subtle mismatch between "who's allowed to touch this row" and "what they're allowed to set on it" that's easy to miss when a policy is written once and never re-examined against how the schema evolves.
Visual consistency at full cohort scale
With all fourteen cohorts (C1–C13, K1) now represented, plain-text cohort labels stopped being enough to scan at a glance — the directory needed a visual shorthand as legible as the data model underneath it. Cohort values are now rendered as colored pills rather than text, using a fourteen-color palette anchored in the two institutions' brand colors (NJCU green, Kean navy blue) and extended through a deliberate academic-regalia-style spread — teal, olive, gold, rust, maroon, plum — so that fourteen values stay visually distinct rather than collapsing into a narrow blue-green gradient that would have been harder to tell apart at small pill size. Paired with the mobile navigation work needed once K1's expanded tab set had to be reachable on small screens, this rounded out the platform's visual language to match the scale the data itself had already reached.
Turning the calendar into real data, not a running list
Early in this build, deciding whether the academic calendar should be manually maintained or derived from assignment due dates got resolved structurally rather than left as an ongoing question — but "derived" turned out to mean two different data sources feeding one view, not one. Assignment due dates already lived in the portfolio_items table and could stay there; general academic events (institute dates, deadlines, cohort-wide milestones) didn't have a natural home in that schema, so they moved into a Google Sheet instead, parsed from ICS exports out of the calendar I'd already been using to track them myself. The calendar component now reads events from the sheet and due dates from Supabase and merges them into one feed — an actual union, not a manual copy-paste between two places that would drift the moment one of them changed and the other didn't.
Merging two sources into one feed surfaced exactly the kind of bug that only shows up once real data from both sides is actually loaded at the same time, not before: duplicate events, where the same milestone existed in both sources and rendered twice, fixed with a client-side dedup keyed on title and date rather than trying to prevent the overlap at the source. A course filter that was supposed to show K1's three active courses instead listed every EDTC course in the program — a stale loop reading from the full course query instead of the label map actually scoped to K1. And a Google Sheets management notice, meant to tell editors where the underlying data lived, was rendering for every visitor regardless of role, wrapped after the fact in the same editor-only check the rest of the admin surface already used.
None of these were subtle bugs to explain, once caught — but none of them were visible from reading the code, either. They only showed up from loading the calendar with both sources populated and actually looking at what rendered, the same lesson the C12/C13 import taught earlier: a merge that looks correct in the abstract still has to be checked against real, populated data before it's actually done.
Bringing faculty into the same system
The directory had, until this point, been built entirely around cohort members — every access decision, every self-edit trigger, assumed the person editing their own row was a student. Adding program faculty (Drs. Zieger, Mason, and Shamburg) meant a different kind of person needed a presence in the same platform, without inheriting permissions that made sense for a peer directory but not for an instructor profile.
Rather than folding faculty into the members table and layering exceptions onto rules built for a different population, they got their own instructors table, with magic-link self-edit access scoped narrowly to just photo_url and contact_details — the same instinct behind restricting members' self-edit to everything except cohort and email, applied to a table where almost nothing should be self-editable in the first place. A faculty profile appears in two places rather than a new page of its own: at the top of the Yearbook, and as a clickable CohortBadge pill on the Courses & Portfolio page that opens the same FacultyCard modal — reusing UI the platform already had instead of inventing a separate faculty-specific pattern.
Live magic-link sign-in is still unverified for all three profiles as of this writing — the profiles render and the self-edit scope is built and tested against a mock session, but I haven't yet watched a real sign-in for Dr. Zieger, Dr. Mason, or Dr. Shamburg complete end to end. "It's built" and "it's verified" aren't the same claim, and the difference matters more here than almost anywhere else in the platform, since this is the one surface where the person I'd be wrong about isn't a peer I can just ask to retest — it's faculty.
Reflection
This project pulled directly on UX fundamentals I hadn't consciously exercised in years — progressive disclosure, error-state design, the difference between authentication and authorization, and the discipline of designing for edge cases before they become support tickets. Working iteratively with an AI development tool didn't remove the need for that thinking; if anything, it sharpened it, since every prompt I wrote was itself a specification, and vague specifications produced exactly the kind of subtle bugs (a merged view that didn't merge for non-editors, a route that rendered before checking access, a fix that landed on one card component but not its sibling, an access policy that correctly checked identity but not scope, or a "hide this" instruction that was read as "remove this") that a clearer UX-first framing would have prevented from the start.
The second import wave added a related but distinct lesson: reconstructing structure from incomplete legacy data is itself a design act, not a data-entry chore. A consistently-applied, explicitly-stated rule for filling gaps — even an imperfect one — is more trustworthy than a cleaner-looking result achieved by ad hoc judgment calls, because the rule can be explained, audited, and corrected later. And every placeholder value generated to fill a required field needs to carry its own uncertainty with it into the system, rather than becoming indistinguishable from data a real person actually provided.
Beyond the technical build, this became a small case study in leadership under ambiguity: building infrastructure for a community whose identity was still being defined, while trying not to impose more structure than the moment actually called for — and, as the platform scaled past the cohort that built it, learning that consistency across surfaces has to be actively maintained, not assumed, and that precision in specifying a fix matters as much as precision in specifying the original feature.
This case study covers the Hub itself — directory, calendar, cross-cohort network, the shared infrastructure everyone else's data lives inside. The individual-portfolio piece — a portal-embedded wizard that gets each Hub member to their own required QE portfolio site without touching a database directly — is Part 2: https://localkine.ai/portfolio/kjc-edtech-portal-part-2.
Tools: Lovable (AI-assisted development), Supabase (backend/auth), Claude (planning & iteration partner)
Outcomes
- A private hub live for K1, C12, and C13 members, covering directory, academic calendar, and course/portfolio tracking
- A privacy-first access model where regular members self-manage their own data, and administrative access is limited to a small editor group
- A cross-cohort connection feature, opened first to C12 and C13, built to scale to any future cohort — K-prefixed or otherwise — without requiring code changes
- A shared, consistently-applied card UI (contact links and bio display) spanning every surface that renders member data, closing a recurring gap where fixes applied to one component silently didn't reach another
- A living reference directory of prior cohorts' own project sites (C1–C13), with archived fallbacks for links that have gone offline over the near-decade since the program began, and a planned (not-yet-built) verification path for older-cohort members whose original enrollment proof no longer resolves
- A full historical backfill of C1–C11 (122 records) from unstructured legacy rosters, with a stated, auditable rule for splitting names and generating clearly-labeled placeholder contact info, rather than leaving those cohorts absent from the directory entirely
- A claim-status model (terms_accepted_at set only by the real join flow, combined with editor approval) that cleanly distinguishes a person who has actually signed up from a placeholder record awaiting them — and gates both email display and email edit-access on that distinction instead of on cohort number
- A unified visual system — cohort color pills spanning all fourteen cohorts, mobile-responsive navigation — bringing the platform's design consistency in line with its now full-decade data scope
Related work
ETL Cohort Hub Portfolio Pipeline, Part 2
The technical build here is the smaller half of the story — the harder part was closing the gap between "I know how to do this" and "someone with zero technical background can actually finish it without me in the room." Most of the real fixes came from watching an actual classmate get stuck, not from re-testing my own assumptions.
Designed and built a portal-embedded wizard that lets non-technical cohort members generate their own QE-compliant portfolio site prompt — no code, no cost, backed by Google Sheets instead of a database they'd have to learn.
Assessment 2: Leadership Principles Video
EDTC 802 | EDTC 6020
A short recorded speech, delivered without notes, articulating one's personal leadership values and principles.
Assignment 4: Constructing Visual Aids
EDTC 803 | EDTC 6030
A short memorandum report featuring a single data visualization built from publicly available federal data on a workforce, economic, or education trend.
Let's connect