diff --git a/AGENTS.md b/AGENTS.md index 216f602d..561a02c8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,8 +29,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..9abc68ed 100644 --- a/docs/people/decisions.md +++ b/docs/people/decisions.md @@ -67,6 +67,27 @@ 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, 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. + +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 +107,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 0bd23082..83271567 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..9b18607e 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) - const [year, setYear] = useState(nextYear(person.profile.year)) - const [campus, setCampus] = useState(person.profile.campus) + // 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 [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 && year && 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,32 +350,47 @@ 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" value={year} + placeholder="Choose your year" + invalid={showMissing && !year} options={YEARS.map((option) => ({ value: option, label: option }))} 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 +423,15 @@ function CensusCard({ area }: { area: Linked }) { ) : null}
- + {answer && ( + + )}
) } @@ -430,9 +461,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-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/src/features/people/people.contract.ts b/src/features/people/people.contract.ts index 55d43982..46f2178b 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(), }) @@ -448,7 +450,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(), @@ -524,7 +526,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} 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") +})