From 42f72db9d0cfd2940dbebe4ec51bd1fc080148d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriele=20Vigan=C3=B2?= Date: Sun, 25 Oct 2026 11:56:31 +0000 Subject: [PATCH 1/3] feat(people): show records filled from PoliNetwork Auth and Microsoft 365 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backend now fills the People register from the roles bound to People in PoliNetwork Auth and from the Microsoft 365 Soci group. Those records start with only a name. - Contract: profile.campus (and the match candidate's) is nullable; course and year may be empty. - Record, Admins and Capo Admin pages show "—" for them, the year filter skips them, and the profile form starts with nothing preselected. - My area asks for course and campus before a census answer. - Applying a census Telegram username asks to complete the profile first when there is no campus. - The Design team is called Design & Marketing. - Docs: where the register's people come from, contract and plan. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 5 +- docs/people/contract.md | 8 +- docs/people/decisions.md | 20 ++++- docs/people/plan.md | 4 + src/features/people/admins-page.tsx | 7 +- src/features/people/capo-page.tsx | 3 +- src/features/people/my-area-page.tsx | 74 +++++++++++++------ src/features/people/people.contract.ts | 8 +- src/features/people/people.fake.server.ts | 2 +- src/features/people/people.fixtures.server.ts | 9 ++- src/features/people/person-dialog.tsx | 2 +- src/features/people/person-page.tsx | 25 ++++--- 12 files changed, 117 insertions(+), 50 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7b43282a..2e13c92f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,8 +28,9 @@ What the app is and how to run it: [`README.md`](README.md). UI rules: [`docs/de sections. Fees and receipts (`docs/people/fees.md`): `people:fees:write` reviews, marks paid and reminds; `people:receipts:sign` downloads and uploads signed receipts for the officer on the caller's record. Preparing CIE requests additionally requires `esign:sign` and reuses the existing eSign UI and agent; the backend - owns the durable receipt/request association and completion (the People backend contract is still pending). Handoff notes, - decisions and the plan: `docs/people/`. + owns the durable receipt/request association and completion (the People backend contract is still pending). The + backend fills the register from PoliNetwork Auth roles bound to People and the Microsoft 365 Soci group, so a + record's course and year may be empty and its campus `null`. Handoff notes, decisions and the plan: `docs/people/`. - **Shared UI**: `src/components/shell`, `src/components/primitives`, `src/components/ui`. ## Conventions diff --git a/docs/people/contract.md b/docs/people/contract.md index 8c32c0b7..f7fe6eeb 100644 --- a/docs/people/contract.md +++ b/docs/people/contract.md @@ -29,9 +29,9 @@ type Profile = { phone: string | null // omitted (null) without contact rights telegramUsername: string | null telegramId: string | null - course: string - year: string // "2nd year", "1st year MSc", "Graduated" - campus: Campus + course: string // "" until known + year: string // "2nd year", "1st year MSc", "Graduated"; "" until known + campus: Campus | null // null until known: records created from PoliNetwork Auth or Microsoft 365 otherAssociations: string[] studentRepresentative: string | null } @@ -256,7 +256,7 @@ type MyArea = name: string course: string year: string - campus: Campus + campus: Campus | null roles: string[] } reason: string diff --git a/docs/people/decisions.md b/docs/people/decisions.md index bee61ae2..c951ec15 100644 --- a/docs/people/decisions.md +++ b/docs/people/decisions.md @@ -67,6 +67,24 @@ Known prototype defects to not reproduce: - People with no active role stay in Overview as muted rows; the Role filter has "No roles". Their record shows a neutral alert: the record stays for history and future census campaigns. +## Where the register's people come from + +The backend keeps the register and fills it from two sources besides the dashboard and census imports (backend +README, "People register sources"): + +- **PoliNetwork Auth roles.** Master Admin binds hand-made roles to People on the role's page in the IdP: `admin`, + `team::member` or `team::lead` (team keys `it`, `hr`, `design`, `intl`, `events`). Their holders get a linked + record with the matching Admin and team entries. PoliNetwork Auth is the source of truth: Add to team, End + membership and End admin role on a linked record give or take back the bound role there for the signed-in user, and + the IdP refuses what that user couldn't do in its own dashboard (no self-grants, Leads need `people:appoint`, nobody + hands out permissions they don't hold). Records without an account, and roles with no bound role, stay owned by + People. +- **The Microsoft 365 Soci group** ("Members only" in Microsoft 365). Each member gets a membership numbered by the + account's `employeeId`. Leaving the group ends nothing; the register keeps the status. + +Records created this way have only a name: course, year and campus show "—" until HR edits the profile, and My area +asks the person to choose them before answering the census. + ## Per page - **Overview**: personal email is the secondary line. Admin is a state badge (Active / Former); Capo Admin a scope chip. @@ -86,7 +104,7 @@ Known prototype defects to not reproduce: Year, Campus, Active since, then Requested, Representative, Other associations and Available as Capo Admin appear as the table widens. Active / Former is the scope control. Row icon: Open in Telegram; the role actions follow it. - **Teams**: index = one `NavCard` per team (lead, member count, missing census answers) then all team members in one - table with a Team column. Team page columns follow the team's fields (IT: GitHub, Areas; Design & Social: Tools, + table with a Team column. Team page columns follow the team's fields (IT: GitHub, Areas; Design & Marketing: Tools, Portfolio; HR and Events: Focus; International: Languages). Team Leads see phone and Telegram for their team. Lead option in Add to team only for `people:appoint`. End membership needs a reason and keeps history. - **Census**: campaign list is a table with a stacked progress meter and counts. Campaign page: Import responses diff --git a/docs/people/plan.md b/docs/people/plan.md index 28af65d0..e48f2390 100644 --- a/docs/people/plan.md +++ b/docs/people/plan.md @@ -198,3 +198,7 @@ Newest last. One line per commit or session: date, what was done, what is next. `people.receipts.prepareSignatures` contract and adapter, both signing permissions, and regression tests for officer restrictions, loader refresh and merged navigation. The People backend bridge remains pending, so agent mode reports that requirement instead of simulating CIE success. + +- 2026-10-25: the register now fills from PoliNetwork Auth's bound roles and the Microsoft 365 Soci group (backend + `feat/people-role-sync`, IdP `feat/people-role-api`). `profile.campus` is nullable and course/year may be empty; + pages show "—", the profile form starts empty and My area asks for them. The Design team is Design & Marketing. diff --git a/src/features/people/admins-page.tsx b/src/features/people/admins-page.tsx index f3883864..cc765d4f 100644 --- a/src/features/people/admins-page.tsx +++ b/src/features/people/admins-page.tsx @@ -126,7 +126,8 @@ function tableColumns(year: string): DataTableColumn[] { width: 112, priority: 4, mobile: "hidden", - cell: (person) => {person.profile.year}, + cell: (person) => + person.profile.year ? {person.profile.year} : , }, { id: "campus", @@ -134,7 +135,7 @@ function tableColumns(year: string): DataTableColumn[] { width: 112, priority: 5, mobile: "hidden", - cell: (person) => person.profile.campus, + cell: (person) => person.profile.campus ?? , }, { id: "since", @@ -280,7 +281,7 @@ export function AdminsPage({ people }: { people: PersonRow[] }) { const filtered = inScope.filter( (person) => (campus === null || person.profile.campus === campus) && - (year === null || yearOf(person.profile.year) === year) && + (year === null || (person.profile.year !== "" && yearOf(person.profile.year) === year)) && [...quick].every((id) => QUICK[id].test(person)) && matchesQuery(person, deferredQuery) ) diff --git a/src/features/people/capo-page.tsx b/src/features/people/capo-page.tsx index 081ac6e0..569692bd 100644 --- a/src/features/people/capo-page.tsx +++ b/src/features/people/capo-page.tsx @@ -87,7 +87,8 @@ export function CapoPage({ area }: { area: CapoArea }) { width: 112, priority: 3, mobile: "hidden", - cell: (person) => {person.profile.year}, + cell: (person) => + person.profile.year ? {person.profile.year} : , }, { id: "since", diff --git a/src/features/people/my-area-page.tsx b/src/features/people/my-area-page.tsx index 8234934a..c98f9c8a 100644 --- a/src/features/people/my-area-page.tsx +++ b/src/features/people/my-area-page.tsx @@ -17,7 +17,7 @@ import { Users, UsersRound, } from "lucide-react" -import { useState } from "react" +import { Fragment, useState } from "react" import { buttonMotion, @@ -136,8 +136,14 @@ function MatchView({ area }: { area: Match }) {
{candidate.name} - {candidate.id} · {candidate.course} · {candidate.year} ·{" "} - {candidate.campus} + {[candidate.id, candidate.course, candidate.year, candidate.campus] + .filter(Boolean) + .map((part, index) => ( + + {index ? " · " : ""} + {index ? part : {part}} + + ))} {candidate.roles.length ? ( @@ -253,9 +259,11 @@ function CensusCard({ area }: { area: Linked }) { const adminGroups = isActiveAdmin(person) ? (person.admin?.groups ?? []) : [] const groups = adminGroups.map((group) => group.chatId) const teams = activeTeams(person).map((membership) => membership.team) - const [course, setCourse] = useState(person.profile.course) + // Records created from PoliNetwork Auth or Microsoft 365 have no course or campus until someone fills them in. + const [course, setCourse] = useState(person.profile.course || null) const [year, setYear] = useState(nextYear(person.profile.year)) - const [campus, setCampus] = useState(person.profile.campus) + const [campus, setCampus] = useState(person.profile.campus) + const [showMissing, setShowMissing] = useState(false) const [keepGroups, setKeepGroups] = useState(groups) const [keepTeams, setKeepTeams] = useState(teams) const [saving, setSaving] = useState(false) @@ -263,7 +271,7 @@ function CensusCard({ area }: { area: Linked }) { const leavingKey = useOpenGeneration(leavingOpen) if (!campaign) return null - const answer = { course, year, campus, keepGroups, keepTeams } + const answer = course && campus ? { course, year, campus, keepGroups, keepTeams } : null // Team roles update immediately; Telegram removals wait for confirmation. if (entry?.answer) { @@ -293,6 +301,10 @@ function CensusCard({ area }: { area: Linked }) { } async function confirmContinuing() { + if (!answer) { + setShowMissing(true) + return + } setSaving(true) try { await save({ data: { ...answer, answer: "continues", reason: "", note: "" } }) @@ -306,9 +318,10 @@ function CensusCard({ area }: { area: Linked }) { setSaving(false) } - const courses = COURSES.some((option) => option === person.profile.course) - ? COURSES - : [person.profile.course, ...COURSES] + const courses = + !person.profile.course || COURSES.some((option) => option === person.profile.course) + ? COURSES + : [person.profile.course, ...COURSES] return ( { - setLeavingOpen(true) + if (answer) setLeavingOpen(true) + else setShowMissing(true) }} > I'm leaving @@ -336,18 +350,24 @@ function CensusCard({ area }: { area: Linked }) {

Check your details and confirm whether you're continuing.

- + ({ value: option, label: option }))} onChange={setCourse} + invalid={showMissing && !course} /> id="census-year" @@ -356,12 +376,18 @@ function CensusCard({ area }: { area: Linked }) { onChange={setYear} /> - + id="census-campus" value={campus} + placeholder="Choose your campus" options={CAMPUSES.map((option) => ({ value: option, label: option }))} onChange={setCampus} + invalid={showMissing && !campus} />
@@ -394,13 +420,15 @@ function CensusCard({ area }: { area: Linked }) { ) : null}
- + {answer && ( + + )}
) } @@ -430,9 +458,9 @@ function LinkedView({ area }: { area: Linked }) {
) : null, }, - { key: "Course", value: profile.course }, - { key: "Year", value: profile.year }, - { key: "Campus", value: {profile.campus} }, + { key: "Course", value: profile.course || null }, + { key: "Year", value: profile.year || null }, + { key: "Campus", value: profile.campus ? {profile.campus} : null }, ...(member || person.account.status === "linked" ? [ { diff --git a/src/features/people/people.contract.ts b/src/features/people/people.contract.ts index b2ecf8be..9a6553f2 100644 --- a/src/features/people/people.contract.ts +++ b/src/features/people/people.contract.ts @@ -70,9 +70,11 @@ export const profileSchema = z.object({ phone: z.string().nullable(), telegramUsername: z.string().nullable(), telegramId: z.string().nullable(), + /** Empty, like `year`, for people known only from PoliNetwork Auth or Microsoft 365 until HR completes them. */ course: z.string(), year: z.string(), - campus: campusSchema, + /** `null` until known: people known only from PoliNetwork Auth or Microsoft 365. */ + campus: campusSchema.nullable(), otherAssociations: z.array(z.string()), studentRepresentative: z.string().nullable(), }) @@ -447,7 +449,7 @@ export const myAreaSchema = z.discriminatedUnion("state", [ name: z.string(), course: z.string(), year: z.string(), - campus: campusSchema, + campus: campusSchema.nullable(), roles: z.array(z.string()), }), reason: z.string(), @@ -523,7 +525,7 @@ export const TEAMS = { ], }, design: { - name: "Design & Social", + name: "Design & Marketing", fields: [ { key: "tools", diff --git a/src/features/people/people.fake.server.ts b/src/features/people/people.fake.server.ts index 46b0b1c9..1fa71049 100644 --- a/src/features/people/people.fake.server.ts +++ b/src/features/people/people.fake.server.ts @@ -822,7 +822,7 @@ export function fakePeopleApi(viewer: PeopleViewer): PeopleApi { ? [{ question: "Why are you leaving?", answer: person.admin?.endReason ?? "Graduating" }] : []), { question: "Course and year", answer: `${person.profile.course}, ${person.profile.year}` }, - { question: "Campus", answer: person.profile.campus }, + { question: "Campus", answer: person.profile.campus ?? "—" }, { question: "Telegram username", answer: `@${person.censusTelegram ?? person.profile.telegramUsername ?? "—"}`, diff --git a/src/features/people/people.fixtures.server.ts b/src/features/people/people.fixtures.server.ts index 71cbe181..89376b56 100644 --- a/src/features/people/people.fixtures.server.ts +++ b/src/features/people/people.fixtures.server.ts @@ -1156,7 +1156,7 @@ export function buildPeopleStore(): PeopleStore { account: { name: "Aurora Fabbri", email: "aurora.fabbri@gmail.com", telegram: "@aurorafab", via: "Telegram" }, record: null, other: null, - message: "I joined Design & Social at the open day.", + message: "I joined Design & Marketing at the open day.", }, { id: "r6", @@ -1193,6 +1193,13 @@ export function buildPeopleStore(): PeopleStore { { id: "j3", personId: (informatics[1] ?? giulia).id, group: "Basi di Dati", requestedOn: "2026-10-12" }, ] + // Someone known only from a role in PoliNetwork Auth: the backend links the record but has no course or campus. + const fromAuth = people.find( + (p) => !(p.id in KEYS) && p.account.status === "linked" && p.teams.length && !p.admin && !p.member + ) + if (fromAuth) + Object.assign(fromAuth.profile, { course: "", year: "", campus: null, personalEmail: null, phone: null }) + return { people, byId, reviews, joinRequests, receipts: seedFees(people, byId) } } diff --git a/src/features/people/person-dialog.tsx b/src/features/people/person-dialog.tsx index de78a2e9..3d367537 100644 --- a/src/features/people/person-dialog.tsx +++ b/src/features/people/person-dialog.tsx @@ -42,7 +42,7 @@ export function PersonDialog({ phone: person?.profile.phone ?? "", telegramUsername: person?.profile.telegramUsername ? `@${person.profile.telegramUsername}` : "", course: person?.profile.course ?? "", - year: person ? yearOf(person.profile.year) : null, + year: person?.profile.year ? yearOf(person.profile.year) : null, campus: person?.profile.campus ?? null, } const [values, setValues] = useState(initial) diff --git a/src/features/people/person-page.tsx b/src/features/people/person-page.tsx index 06f983c8..d380562b 100644 --- a/src/features/people/person-page.tsx +++ b/src/features/people/person-page.tsx @@ -23,7 +23,7 @@ import { UserRoundCog, Users, } from "lucide-react" -import { useState } from "react" +import { Fragment, useState } from "react" import { buttonMotion, @@ -131,6 +131,11 @@ export function PersonPage({ person }: { person: PersonRecord }) { } async function applyCensusTelegram(username: string) { + // The update re-sends the whole profile, which needs a campus. + if (!profile.campus) { + appToast.error("Complete the profile first: it has no campus.") + return + } setApplyingTelegram(true) try { await update({ @@ -171,9 +176,9 @@ export function PersonPage({ person }: { person: PersonRecord }) { ) : null, }, - { key: "Course", value: profile.course }, - { key: "Year", value: profile.year }, - { key: "Campus", value: {profile.campus} }, + { key: "Course", value: profile.course || null }, + { key: "Year", value: profile.year || null }, + { key: "Campus", value: profile.campus ? {profile.campus} : null }, { key: "Other associations", value: profile.otherAssociations.length ? ( @@ -199,12 +204,12 @@ export function PersonPage({ person }: { person: PersonRecord }) { meta={ {person.id} - · - {profile.course} - · - {profile.year} - · - {profile.campus} + {[profile.course, profile.year, profile.campus].filter(Boolean).map((part) => ( + + · + {part} + + ))} } chips={roles.length ? roles.map((role) => {role}) : undefined} From 3963e376b5355fa14b292ed0cb74b9c436f5f398 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriele=20Vigan=C3=B2?= Date: Mon, 26 Oct 2026 10:44:24 +0000 Subject: [PATCH 2/3] fix(people): ask for the census year when the record has none Records filled from PoliNetwork Auth or Microsoft 365 have no academic year, and the census pre-filled it as "2nd year". An unknown year now starts empty and must be chosen, like the course and campus. Co-Authored-By: Claude Opus 5.5 --- src/features/people/my-area-page.tsx | 7 +++++-- src/features/people/people-format.ts | 3 ++- tests/people-census-year.test.mjs | 20 ++++++++++++++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) create mode 100644 tests/people-census-year.test.mjs diff --git a/src/features/people/my-area-page.tsx b/src/features/people/my-area-page.tsx index c98f9c8a..9b18607e 100644 --- a/src/features/people/my-area-page.tsx +++ b/src/features/people/my-area-page.tsx @@ -261,7 +261,7 @@ function CensusCard({ area }: { area: Linked }) { const teams = activeTeams(person).map((membership) => membership.team) // Records created from PoliNetwork Auth or Microsoft 365 have no course or campus until someone fills them in. const [course, setCourse] = useState(person.profile.course || null) - const [year, setYear] = useState(nextYear(person.profile.year)) + const [year, setYear] = useState(nextYear(person.profile.year)) const [campus, setCampus] = useState(person.profile.campus) const [showMissing, setShowMissing] = useState(false) const [keepGroups, setKeepGroups] = useState(groups) @@ -271,7 +271,7 @@ function CensusCard({ area }: { area: Linked }) { const leavingKey = useOpenGeneration(leavingOpen) if (!campaign) return null - const answer = course && campus ? { course, year, campus, keepGroups, keepTeams } : null + const answer = course && year && campus ? { course, year, campus, keepGroups, keepTeams } : null // Team roles update immediately; Telegram removals wait for confirmation. if (entry?.answer) { @@ -367,11 +367,14 @@ function CensusCard({ area }: { area: Linked }) { id="census-year" value={year} + placeholder="Choose your year" + invalid={showMissing && !year} options={YEARS.map((option) => ({ value: option, label: option }))} onChange={setYear} /> diff --git a/src/features/people/people-format.ts b/src/features/people/people-format.ts index 9ed3bb05..367f1904 100644 --- a/src/features/people/people-format.ts +++ b/src/features/people/people-format.ts @@ -60,7 +60,8 @@ const NEXT_YEAR = { /** Last year's answer advanced by one, to pre-fill this year's census. */ export function nextYear(value: string) { - return NEXT_YEAR[yearOf(value)] + const known = YEARS.find((year) => year === value) + return known ? NEXT_YEAR[known] : null } export function teamName(team: TeamId) { diff --git a/tests/people-census-year.test.mjs b/tests/people-census-year.test.mjs new file mode 100644 index 00000000..d13c8610 --- /dev/null +++ b/tests/people-census-year.test.mjs @@ -0,0 +1,20 @@ +import assert from "node:assert/strict" +import { registerHooks } from "node:module" +import test from "node:test" + +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier.startsWith("@/")) + return nextResolve(new URL(`../src/${specifier.slice(2)}.ts`, import.meta.url).href, context) + if (specifier.startsWith(".") && !/\.[cm]?[jt]s$/.test(specifier)) + return nextResolve(new URL(`${specifier}.ts`, context.parentURL).href, context) + return nextResolve(specifier, context) + }, +}) +const { nextYear } = await import("../src/features/people/people-format.ts") +void test("census never invents an academic year for an incomplete or unknown profile", () => { + assert.equal(nextYear(""), null) + assert.equal(nextYear("unknown"), null) + assert.equal(nextYear("1st year"), "2nd year") + assert.equal(nextYear("Graduated"), "Graduated") +}) From fd5f78fa1b8f417a0c333939b6e7443d63d25619 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriele=20Vigan=C3=B2?= Date: Mon, 26 Oct 2026 10:46:27 +0000 Subject: [PATCH 3/3] docs(people): describe confirmed role commands to PoliNetwork Auth Co-Authored-By: Claude Opus 5.5 --- docs/people/decisions.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/people/decisions.md b/docs/people/decisions.md index c951ec15..9abc68ed 100644 --- a/docs/people/decisions.md +++ b/docs/people/decisions.md @@ -75,10 +75,13 @@ README, "People register sources"): - **PoliNetwork Auth roles.** Master Admin binds hand-made roles to People on the role's page in the IdP: `admin`, `team::member` or `team::lead` (team keys `it`, `hr`, `design`, `intl`, `events`). Their holders get a linked record with the matching Admin and team entries. PoliNetwork Auth is the source of truth: Add to team, End - membership and End admin role on a linked record give or take back the bound role there for the signed-in user, and - the IdP refuses what that user couldn't do in its own dashboard (no self-grants, Leads need `people:appoint`, nobody - hands out permissions they don't hold). Records without an account, and roles with no bound role, stay owned by - People. + membership, End admin role, census answers and account links on a linked record send it one command for the + signed-in user, and the IdP applies all of it or nothing, refusing what that user couldn't do in its own dashboard + (no self-grants, Leads need `people:appoint`, nobody hands out permissions they don't hold). A role takes effect in + People only once the IdP confirms it: until then the record keeps its previous roles and other role edits of that + person wait. Linking a record ends the roles recorded before it until the IdP confirms them; a refusal outside a + direct dashboard action flags the record for HR. Records without an account, and roles with no bound role, stay + owned by People. - **The Microsoft 365 Soci group** ("Members only" in Microsoft 365). Each member gets a membership numbered by the account's `employeeId`. Leaving the group ends nothing; the register keeps the status.