From 1b45c50e3b755ab8c98e3f6839c479ac903c0419 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 12:11:00 +0000 Subject: [PATCH 01/24] docs(people): add People area handoff docs and prototype Brief, settled decisions, backend contract and phased plan for the People area, plus the prototype source as a structure and copy reference. The prototype is excluded from format and lint checks. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ti91VKqKpduLAF376TunMn --- docs/people/README.md | 29 + docs/people/brief.md | 181 +++ docs/people/contract.md | 251 +++ docs/people/decisions.md | 124 ++ docs/people/plan.md | 121 ++ docs/people/prototype/README.md | 14 + docs/people/prototype/prototype.css | 545 +++++++ docs/people/prototype/prototype.dc.html | 1899 +++++++++++++++++++++++ vite.config.ts | 4 +- 9 files changed, 3166 insertions(+), 2 deletions(-) create mode 100644 docs/people/README.md create mode 100644 docs/people/brief.md create mode 100644 docs/people/contract.md create mode 100644 docs/people/decisions.md create mode 100644 docs/people/plan.md create mode 100644 docs/people/prototype/README.md create mode 100644 docs/people/prototype/prototype.css create mode 100644 docs/people/prototype/prototype.dc.html diff --git a/docs/people/README.md b/docs/people/README.md new file mode 100644 index 00000000..a318f4af --- /dev/null +++ b/docs/people/README.md @@ -0,0 +1,29 @@ +# People area: handoff + +Everything an agent needs to build, or continue building, the People area of the dashboard. + +| File | What it is | +| ------------------------------ | ---------------------------------------------------------------------------------------- | +| [`brief.md`](brief.md) | The original brief, verbatim | +| [`decisions.md`](decisions.md) | What was settled after the brief: owner decisions, navigation, per-page rules, roles | +| [`contract.md`](contract.md) | The `people.*` backend contract the dashboard codes against | +| [`plan.md`](plan.md) | Phases, checklist and progress log: **start here to know what is done and what is next** | +| [`prototype/`](prototype/) | The clickable prototype's source (structure and copy reference only) | + +## Continuing the work + +1. Read `CLAUDE.md` and `docs/design.md`, then `decisions.md`, `contract.md` and `plan.md` in that order. +2. Check out branch `claude/eager-mayer-bsom7v` and read the open pull request and its latest commits. +3. Pick the first unchecked item in `plan.md`. Commit small and often; tick the box and add a progress-log line in the + commit that finishes an item. +4. Run `PORT=1xxxx AGENT_MODE=true pnpm dev` to see the pages with sample data; set `AGENT_PERMISSIONS` and + `AGENT_PERSON` (once phase 1 adds them) to preview restricted roles. + +## The prototype has visual errors + +The prototype fixes what each page shows and does, not how it measures. Its spacing, paddings, column widths and some +components are hand-made and wrong in places. Do not copy its CSS or markup: build from the repo's shell and primitives +so spacing and behaviour match the rest of the dashboard, and fix anything that looks off. See "Prototype is a +reference" in `decisions.md` for the known defects. + +Prototype canvas (owner only, all pages in desktop/mobile × light/dark): diff --git a/docs/people/brief.md b/docs/people/brief.md new file mode 100644 index 00000000..7fb8df8c --- /dev/null +++ b/docs/people/brief.md @@ -0,0 +1,181 @@ +# People area: original brief + +This is the brief the prototype was built from, kept verbatim so later work answers to the same requirements. Where it +conflicts with [`decisions.md`](decisions.md), the decisions win: they record what was settled afterwards. + +--- + +Prototype a new "People" area for the PoliNetwork Admin dashboard: a clickable, high-fidelity prototype that fits the +existing dashboard exactly. Desktop 1440px and mobile 390px, light and dark theme. + +## Context + +PoliNetwork is a student association at Politecnico di Milano. It runs hundreds of Telegram/WhatsApp course groups +through ~150 volunteer group admins, plus internal teams (IT, HR, Design & Social, International, Events & +Partnerships) and a smaller set of paying members (soci). + +Today volunteer data lives in a Microsoft Forms spreadsheet ("Modulo Volontari", yearly), and members live only as +Microsoft 365 accounts. This area brings everything into the dashboard: one record per person, and on that record three +independent roles: + +- Member (socio): has a member number and a yearly membership status. +- Admin: manages Telegram groups for courses. +- Team member: belongs to one or more internal teams, possibly as Team Lead. + +A person can have any combination of roles, or none (e.g. a former volunteer). The data collected differs by role: +don't merge admin fields and team fields into one table. + +Key concept: a person record exists even if the person never signs in. Many volunteers will never create a PoliNetwork +account. When someone does sign in later, their account gets linked to their existing record (never a duplicate). Every +record therefore shows an account status: Not linked · Invite sent · Linked · Needs review. + +## Existing design system (follow strictly) + +- Shell: 64px left rail with service icons (Overview logo, Search, then Telegram, WhatsApp, Microsoft 365, Web, Reports; + theme toggle and avatar at the bottom), a 240px section panel for services with 2+ sections, then a scrolling content + column with max width 1280px. Below 760px: 56px top bar, horizontally scrolling section pills, 64px bottom nav; tables + become stacked cards; dialogs become bottom sheets. +- Add "People" as a new rail service (monochrome lucide icon such as UsersRound) with its own section panel. +- Page header: one h1 (24px/600) with one muted context line (usually the total, e.g. "148 people"), and at most one + primary action plus one outline action. Then a toolbar row: scope controls → search ("/" shortcut) → filters → count + shown only while filtered ("12 of 148 people"). +- Tables: 12px radius surface, 1px borders, sentence-case 12px headers (no uppercase), 52px rows, 14px cells, first + column with a 32px initials avatar and an optional 12px secondary line. Columns of variable length get fixed widths, + and on narrower widths low-priority columns hide first. A visible "Actions" column holds monochrome 36px icon buttons + with the overflow "⋮" menu far right. Pagination: "Showing 1–20 of 148", 20/50/100 per page. Clickable rows hover + with the soft accent fill. +- Record pages (deep pages): back button + breadcrumb ("People / Giulia Rossi"), then a record header with a 64px + avatar, a 28px name, status badge and meta line, then section cards. +- Tokens: canvas #f6f7f9, surfaces #fff, panel #fbfcfd, one accent cobalt #1156ae (active location, primary action, + links, focus only), neutral grays otherwise. Borders separate, shadows only on floating layers (menus, dialogs, + toasts). Radii: 8 buttons, 12 cards and tables, 14 dialogs, full for badges. Spacing on a 4px grid, 16px card padding. +- Type: DM Sans; DM Mono for identifiers (member numbers, Telegram IDs, emails of Microsoft 365 accounts, academic years + like 2026/27). Weights 400/500/600. No uppercase, no italic, tabular numbers. +- Badges: status badges (dot + label) only for states, tones neutral/brand/success/warning/danger. Chips for categorical + values (team names, roles, campus). Show nothing when a state is absent, and "—" for unset values. +- Dialogs: 18px title + one-sentence description; footer right-aligned with "Cancel" (outline) then the primary action. + Confirmations are titled "{Verb} {object}?" and state the consequence. Forms have the label above the field, + "(optional)" on optional fields, errors on submit. +- Copy: English, sentence case, verb + object buttons ("Import responses", "Send invite"), no "please", "successfully" + or exclamation marks. Toasts: "Invite sent.", "Couldn't link account." Dates: "6 Oct 2026". Write "Microsoft 365", + never "Azure". +- Motion: only for changes the user caused, short and ease-out. No animation on navigation, sorting or filtering. + +## Navigation of the new area + +People (service) + +- Overview: all people +- Member register +- Admins +- Teams (→ one page per team) +- Census +- Linking + +Plus, outside the People service: + +- "Capo Admin": a section visible only to Capo Admins +- "My area": the personal page of any signed-in person with a linked record (it can live with the existing Account + page; propose the best placement) + +## Pages to prototype + +1. People › Overview: one row per person. Columns: name (secondary line: personal email), Member (member number chip or + —), Admin (Active / Former), Teams (chips with "+n" overflow), Capo Admin (scope chip), Account (status badge), Last + census (e.g. "2026/27 · Continues"). Filters: role (Member / Admin / Team member / none), team, campus, account + status, census status. Primary action: "Add person". + +2. Person record (deep page): header with name, account status badge and meta ("P-0142 · Ingegneria Informatica · 2nd + year · Leonardo"). Section cards: + - Profile: personal email, phone, Telegram username, course, year, campus, other associations, student + representative (where). + - Member: number, status, admitted on, current year fee status. Link to the Microsoft 365 account. + - Admin: active since, groups they administer (detected by the bot, with Telegram glyphs), groups they asked to join, + available as Capo Admin, Capo Admin of reference. + - Teams: one row per team with role (Member/Lead), since, and the team-specific fields. + - Census history: one row per academic year with answer and source ("Imported from Forms" / "Dashboard"). + - Consents: volunteer privacy policy (version, date), photo/video (granted/denied, revocable). + - Account: linked or not, and how. Actions: "Send invite", "Link account", "Unlink". + - Activity: who changed what and when. Show discrepancies as warning inline alerts, e.g. "Declared a member in the + 2026/27 census, but not in the member register." + +3. Member register: member number (mono, sortable), name, status (Active / Suspended / Expired / Former), admitted on, + fee for the current year (Due / Receipt uploaded / Paid / Unpaid), Microsoft 365 address, account. Actions: "Add + member", "Change status", "Change member number" (confirm dialog explaining the Microsoft 365 account is updated + too), "Export" (confirm, since it exports personal data). + +4. Admins: name, course, year, campus, active since, groups administered (count + chips), groups requested, student + representative, other associations, available as Capo Admin, Capo Admin of reference, member yes/no, census status. + Quick filters: "No groups", "Available as Capo Admin", "Declared member but not registered", "No census answer". + +5. Teams: + - Index: one card per team (lead avatar, member count, missing census answers), then one table of all team members + with a Team column. + - Team page (e.g. "IT"): active and former members (segmented control), role, since, team-specific fields (IT: GitHub + username, areas such as Frontend/Backend/Bot/Infrastructure; Design & Social: tools, portfolio link), census + status, photo/video consent. The Team Lead can "Add to team", edit team fields and "End membership" (with a + reason). + +6. Census: + - Campaign list (2026/27 open, 2025/26 closed) with progress: expected / answered / continuing / leaving / no answer. + - Campaign page: those four segments as a segmented filter over the list of people. Actions: "Import responses", + "Send reminders" (confirm with count), "Close campaign". + - Import wizard (lg dialog with steps): 1) upload the Forms export (.xlsx/.csv); 2) preview with counts and tabs for + New people, Updates, Possible duplicates, Invalid rows and Discrepancies; 3) resolve duplicates side by side, + choosing "Same person" or "Different people"; 4) confirm Telegram username → Telegram user suggestions + (individually or "Confirm all"); 5) final confirm "Import 148 responses" stating nothing is deleted and "No" + answers end the role with its reason. Re-importing the same file must clearly say "Already imported" rows are + skipped. + +7. Linking: two lists. + - "Needs review": conflicts such as "Telegram account already linked to another record", or signed-in people without + a record who asked to be added. Resolve via dialogs. + - "Pending invites": email, sent on, expires on, with "Resend" and "Revoke". Include the "Link account" dialog: search + IdP accounts, show suggested matches with the reason ("Same verified Telegram account" / "Similar name — check + before linking"), a required reason field, and confirm. + +8. Capo Admin (scoped): header "Ingegneria Informatica · 3 groups". The admins of my groups with name, year, active + since, representative, other associations, phone, and Telegram with quick "Open in Telegram" / "Open in WhatsApp" + buttons. My groups, flagging groups without admins. Requests to join my groups. + +9. My area (self-service), for a linked person: my profile (with "Request a correction"), my roles (member number and + status; admin groups; teams), the open census ("Confirm for 2026/27" or "I'm leaving", with fields pre-filled from + last year), my consents (with revoke), my Capo Admin and my Team Lead, and my team's roster (name, role, Telegram, + PoliNetwork email only). Also show: + - the first-time link flow: "Is this you?" with the matched record summary and "Yes, link my account" / "This isn't + me"; + - the state for a signed-in person with no record: "You're not on the volunteer or member lists", with "Ask to be + added". + +## Role-based views + +Add a prototype-only "Viewing as" switcher (outside the real UI chrome, clearly a prototype control) to preview the same +screens as: + +- Direttivo: everything, the only role that writes the member register and appoints Team Leads and Capo Admins. +- HR: Overview, Admins, Teams, Census, Linking with write access; member register read-only (no write actions shown). +- Capo Admin: only the Capo Admin section and My area. +- Team Lead (IT): the IT team page with full fields and contacts, plus My area. +- Team member (IT): My area and the IT roster (name, role, Telegram, PoliNetwork email; no phone, no notes). +- Admin with no other role: My area only. + +Missing rights remove actions and sections entirely: no disabled buttons and no locked icons. Field visibility: phone and +personal email are visible to Direttivo, HR, Capo Admins (their scope) and Team Leads (their team) only. HR notes and +full census answers are visible to Direttivo and HR only. + +## States to include + +Loading skeletons matching each template, empty states ("No people match" with "Clear filters"; "No campaigns yet"), an +import with errors, a link conflict, an expired invite, and a person with no roles left (archived look). + +## Sample data + +Use realistic Italian names, Politecnico di Milano courses (Ingegneria Informatica, Ingegneria Gestionale, Design della +Comunicazione, Architettura…), years (1st–3rd bachelor, 1st–2nd master, graduated) and campuses (Leonardo, Bovisa, +Cremona, Piacenza, Mantova, Lecco, External). Show ~150 people overall, with only ~40 having a linked account, ~60 +members, ~110 admins and ~25 team members, and some overlaps. + +## Deliverable + +A navigable prototype of the pages above with the "Viewing as" switcher. For each page, add a short note on decisions +you made where this brief was ambiguous. diff --git a/docs/people/contract.md b/docs/people/contract.md new file mode 100644 index 00000000..6be375ce --- /dev/null +++ b/docs/people/contract.md @@ -0,0 +1,251 @@ +# People area: backend contract + +The dashboard calls these procedures under `people.*` on the tRPC backend (`@polinetwork/backend`). They do not exist +yet: this file is the spec for the backend project, and the dashboard's own copy of the types lives in +`src/features/people/people.contract.ts` (the source of truth once code exists; keep both in sync). In agent mode the +dashboard serves the same shapes from `src/features/people/people.fixtures.server.ts`. + +Every procedure checks permissions server-side; the "Needs" column is the minimum. Dates are ISO `YYYY-MM-DD` strings +(`Europe/Rome`); timestamps ISO 8601. Academic years are strings like `"2026/27"`. + +## Shared types + +```ts +type PersonId = string // "P-0142" +type AccountStatus = "none" | "invite" | "linked" | "review" +type MemberStatus = "active" | "suspended" | "expired" | "former" +type FeeStatus = "due" | "receipt" | "paid" | "unpaid" +type CensusAnswer = "continues" | "leaving" +type CensusSource = "forms" | "dashboard" +type TeamId = "it" | "hr" | "design" | "intl" | "events" +type TeamRole = "member" | "lead" +type Campus = "Leonardo" | "Bovisa" | "Cremona" | "Piacenza" | "Mantova" | "Lecco" | "External" + +type Profile = { + firstName: string + lastName: string + personalEmail: string | null // omitted (null) without contact rights + 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 + otherAssociations: string[] + studentRepresentative: string | null +} + +type Membership = { + number: string // "0412" + status: MemberStatus + admittedOn: string + fee: FeeStatus | null // current academic year; null for former members + m365Email: string +} + +type AdminRole = { + status: "active" | "former" + since: string + endedOn: string | null + endReason: string | null + groups: { chatId: string; title: string }[] // detected by the bot + requested: { chatId: string; title: string; requestedOn: string }[] + availableAsCapo: boolean + capoOfReference: { id: PersonId; name: string } | null + capoScope: string | null // set when this person is a Capo Admin, e.g. "Ingegneria Informatica" +} + +type TeamMembership = { + team: TeamId + role: TeamRole + since: string + endedOn: string | null + endReason: string | null + fields: Record // per team, see TEAM_FIELDS +} + +type CensusEntry = { + year: string + answer: CensusAnswer | null // null = expected, no answer + answeredOn: string | null + source: CensusSource | null + declaredMember: boolean | null + reminders: number +} + +type Account = + | { status: "none" } + | { status: "invite"; email: string; sentOn: string; expiresOn: string; sentBy: string } + | { status: "linked"; email: string; via: string; linkedOn: string; how: string } + | { status: "review"; reviewId: string; issue: string } + +type PersonSummary = { + id: PersonId + name: string + personalEmail: string | null + course: string + year: string + campus: Campus + telegramUsername: string | null + member: Pick | null + admin: + | (Pick< + AdminRole, + "status" | "since" | "groups" | "requested" | "availableAsCapo" | "capoOfReference" | "capoScope" + > & { + studentRepresentative: string | null + otherAssociations: string[] + }) + | null + teams: Pick[] + account: AccountStatus + lastCensus: { year: string; answer: CensusAnswer } | null + census2627: CensusEntry | null // current campaign entry + photoConsent: "granted" | "denied" | null +} + +type PersonRecord = { + id: PersonId + profile: Profile + member: Membership | null + admin: AdminRole | null + teams: TeamMembership[] + census: CensusEntry[] + consents: { + privacy: { version: string; acceptedOn: string } | null + photo: { status: "granted" | "denied" | "revoked"; on: string } | null + } + account: Account + notes: string | null // null without people:read + discrepancies: { + kind: "declared-member" | "telegram-mismatch" | "needs-review" + message: string + suggestedTelegram?: string + }[] + activity: { actor: string; text: string; at: string }[] +} +``` + +Team fields (`TEAM_FIELDS`): `it` → `github` (text), `areas` (Frontend, Backend, Bot, Infrastructure); `design` → +`tools` (Figma, Illustrator, Photoshop, After Effects, InDesign, Premiere), `portfolio` (URL); `hr` and `events` → +`focus` (multi); `intl` → `languages` (multi). + +## Procedures + +| Procedure | Kind | Input | Output | Needs | +| ---------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | +| `people.persons.list` | query | — | `PersonSummary[]` | `people:read` | +| `people.persons.get` | query | `{ id }` | `PersonRecord` | `people:read` | +| `people.persons.create` | mutation | `Profile` fields + `{ roles: ("admin")[] }` | `{ id }` | `people:write` | +| `people.persons.update` | mutation | `{ id } & Partial` | `void` | `people:write` | +| `people.persons.setNote` | mutation | `{ id, note: string \| null }` | `void` | `people:write` | +| `people.members.create` | mutation | `{ personId, number, admittedOn, fee }` | `{ m365Email }` | `people:members:write` | +| `people.members.setStatus` | mutation | `{ personId, status, reason? }` | `void` | `people:members:write` | +| `people.members.setNumber` | mutation | `{ personId, number }` | `void` (also updates the M365 member ID) | `people:members:write` | +| `people.members.export` | mutation | — | `{ filename, csv }` | `people:members:write` | +| `people.admins.end` | mutation | `{ personId, reason }` | `void` | `people:write` | +| `people.admins.setCapoScope` | mutation | `{ personId, scope: string \| null }` | `void` | `people:appoint` | +| `people.admins.setCapoOfReference` | mutation | `{ personId, capoId: PersonId \| null }` | `void` | `people:write` | +| `people.teams.list` | query | — | `{ id, name, lead, members: (PersonSummary & { membership: TeamMembership; phone: string \| null })[] }[]` | `people:read`, or Lead of that team (only their team) | +| `people.teams.addMember` | mutation | `{ teamId, personId, role, since, fields }` | `void` | `people:write` or Lead (role `lead` needs `people:appoint`) | +| `people.teams.updateFields` | mutation | `{ teamId, personId, fields }` | `void` | `people:write` or Lead | +| `people.teams.endMembership` | mutation | `{ teamId, personId, lastDay, reason, note? }` | `void` | `people:write` or Lead | +| `people.census.campaigns` | query | — | `{ id, year, status: "open" \| "closed", openedOn, closesOn, expected, continuing, leaving, noAnswer }[]` | `people:read` | +| `people.census.campaign` | query | `{ id }` | campaign + `PersonSummary[]` with that year's `CensusEntry` | `people:read` | +| `people.census.answers` | query | `{ personId, year }` | `{ question, answer }[]` | `people:read` | +| `people.census.remind` | mutation | `{ campaignId, personId? }` | `{ sent, skipped }` (skips reminded < 3 days) | `people:write` | +| `people.census.close` | mutation | `{ campaignId }` | `void` | `people:write` | +| `people.census.importPreview` | mutation | `FormData` (`campaignId`, `file` .xlsx/.csv) | `ImportPreview` | `people:write` | +| `people.census.importCommit` | mutation | `{ importId, duplicates: Record, telegram: Record }` | `{ imported }` | `people:write` | +| `people.linking.reviews` | query | — | `Review[]` | `people:read` | +| `people.linking.resolve` | mutation | `{ reviewId, resolution, reason }` | `void` | `people:write` | +| `people.linking.dismiss` | mutation | `{ reviewId }` | `void` | `people:write` | +| `people.linking.invites` | query | — | `{ personId, name, email, sentOn, expiresOn, sentBy }[]` | `people:read` | +| `people.linking.sendInvite` | mutation | `{ personId }` (sends or resends; the old link stops working) | `void` | `people:write` | +| `people.linking.revokeInvite` | mutation | `{ personId }` | `void` | `people:write` | +| `people.linking.searchAccounts` | query | `{ personId, query }` | `{ id, name, email, telegram, via, linkedTo: { id, name } \| null, reason: "same-telegram" \| "same-email" \| "similar-name" }[]` | `people:write` | +| `people.linking.link` | mutation | `{ personId, accountId, reason }` (fails `CONFLICT` when the account is linked elsewhere) | `void` | `people:write` | +| `people.linking.unlink` | mutation | `{ personId }` | `void` | `people:write` | +| `people.capo.get` | query | — | `{ scope, groups: { chatId, title, admins: number }[], admins: (PersonSummary & { phone: string \| null })[], requests: { id, person, group, requestedOn }[] }` | Capo Admin (scope from record) | +| `people.capo.approve` / `.decline` | mutation | `{ requestId }` | `void` | Capo Admin of that group | +| `people.me.get` | query | — | `MyArea` (below) | `people:self` | +| `people.me.confirmMatch` | mutation | — | `void` | `people:self` | +| `people.me.rejectMatch` | mutation | — | `void` (creates a Needs review item) | `people:self` | +| `people.me.askToBeAdded` | mutation | `{ roles: string[], groupsOrTeam?, telegramUsername? }` | `void` | `people:self` | +| `people.me.answerCensus` | mutation | `{ answer, course, year, campus, keepGroups: string[], keepTeams: TeamId[], reason?, note? }` | `void` | `people:self` | +| `people.me.requestCorrection` | mutation | `{ field, value, note? }` | `void` | `people:self` | +| `people.me.revokePhotoConsent` | mutation | — | `void` | `people:self` | + +```ts +type Review = { + id: string + kind: "telegram-conflict" | "ask-to-be-added" | "two-records" | "not-me" | "name-only" + since: string + account: { name: string; email: string; telegram: string | null; via: string } + record: { id: PersonId; name: string } | null + other: { id: PersonId; name: string } | null + message: string | null +} + +type ImportPreview = { + importId: string + fileName: string + rows: number + counts: { + newPeople: number + updates: number + duplicates: number + invalid: number + discrepancies: number + alreadyImported: number + leaving: number + } + tabs: Record< + "newPeople" | "updates" | "duplicates" | "invalid" | "discrepancies" | "alreadyImported", + { row: number; name: string; detail: string; answer: CensusAnswer | null }[] + > + duplicates: { + pairId: string + row: number + file: Record + record: { id: PersonId } & Record + }[] + telegram: { + rowId: string + name: string + username: string + suggestion: { telegramId: string; name: string; info: string } | null + }[] +} + +type MyArea = + | { + state: "linked" + person: PersonRecord + campaign: { year: string; closesOn: string; entry: CensusEntry } | null + contacts: { + role: "capo" | "lead" | "hr" + name: string + telegram: string | null + email: string | null + team?: TeamId + }[] + roster: { + team: TeamId + members: { name: string; role: TeamRole; telegram: string | null; email: string | null }[] + } | null + } + | { + state: "match" + candidate: { id: PersonId; name: string; course: string; year: string; campus: Campus; roles: string[] } + reason: string + } + | { state: "none"; request: { sentOn: string } | null } +``` + +## Permissions + +New IdP permissions (add to `src/server/permissions.ts`): `people:self`, `people:read`, `people:write`, +`people:members:read`, `people:members:write`, `people:appoint`. Capo Admin and Team Lead are not permissions: they +come from the signed-in person's record (`people.me.get`), and the backend scopes `people.capo.*` and team writes to +them. diff --git a/docs/people/decisions.md b/docs/people/decisions.md new file mode 100644 index 00000000..97763b6d --- /dev/null +++ b/docs/people/decisions.md @@ -0,0 +1,124 @@ +# People area: decisions + +Settled decisions on top of [`brief.md`](brief.md). When the brief, the prototype and this file disagree, this file +wins; when this file and [`docs/design.md`](../design.md) disagree on visuals, `docs/design.md` wins. + +## Decisions made with the owner + +1. **Dashboard only.** `@polinetwork/backend` 1.4.0 has no People procedures (only `auth`, `azure`, `groups`, `me`, + `test`). This repo implements the whole UI against the typed contract in [`contract.md`](contract.md). Agent mode + serves in-memory sample data; outside agent mode the server functions call `people.*` on the backend, and the area + stays hidden until the IdP grants the new permissions. The backend work is a separate project that implements the + same contract. +2. **Restricted access.** People without `admin:access` can enter the dashboard when they hold `people:self`. They see + only My area, plus Capo Admin or their team when their record gives them that role. Every other service stays + behind `admin:access`. + +## Prototype is a reference, not a pixel spec + +The prototype (`prototype/`, and the canvas at , private to the +owner) fixes **structure, content, copy and behaviour**: which pages exist, what each shows, column sets, actions, +dialogs, states and role rules. It was hand-built without the real components and **has visual errors**: wrong or +uneven spacing, paddings off the 4px rhythm, hand-tuned column widths, approximate chip overflow, ad-hoc CSS for things +the repo already has primitives for. Never port `prototype.css` or its markup. Build every screen from +`@/components/shell` and `@/components/primitives` (DataTable, PageBar, PageContent, RecordHeader, SectionCard, +KeyValueList, StatusBadge, Chip, ChipOverflow, SegmentedControl, FormDialog, ConfirmDialog, InlineAlert, EmptyState, +skeletons…) so spacing, radii and motion come out right by construction. Where the prototype and a primitive disagree, +the primitive is right. + +Known prototype defects to not reproduce: + +- Card headers use 12px/16px uneven padding; use `SectionCard`. +- Filter buttons render "Role:" and the value as separate flex items; use the repo's filter control pattern. +- Chip overflow is estimated from character counts; use `ChipOverflow`. +- Quick filters on Admins are ad-hoc pills; use the repo's toggle/segmented pattern or filter menu (decide in code, + note it in `docs/design.md` §8). +- Campaign rows reuse a person avatar showing "26"; a campaign row has no avatar. +- Skeletons are hand-drawn; use the template skeletons (`TableSkeleton`, `RecordSkeleton`, `CardsSkeleton`, + `SettingsListSkeleton`). +- Toolbar, table footer and dialog paddings are hand-set; take them from the primitives. +- Some actions only toast ("Appoint as Capo Admin", "Start campaign", "Open in Telegram/WhatsApp"); in code they get real + dialogs or links (Telegram `https://t.me/{username}`, WhatsApp `https://wa.me/{digits}`). +- The "/" search shortcut is drawn but not wired; in code it is the shell's existing `/` behaviour. + +## Navigation + +- **People** is a rail service (`UsersRound`), placed first after Search. Sections: Overview, Member register, Admins, + Teams, Census, Linking. Deep pages: person (`/dashboard/people/$personId`), team (`/dashboard/people/teams/$teamId`), + campaign (`/dashboard/people/census/$campaignId`). +- **Capo Admin** is a Telegram section (`/dashboard/telegram/capo`, icon `Crown`), visible only to Capo Admins. A Capo + Admin without other rights sees Telegram with that one section, so no panel (one-section rule). +- **My area** lives under the avatar: Account becomes a two-section service, My area (`/dashboard/account/me`, default) + and Sign-in & security (today's `/dashboard/account`, unchanged content). +- A **Team Lead** without people rights gets People with only Teams, which opens their team directly. +- People with only `people:self`: the logo links to My area instead of Overview. +- Linking shows its needs-review count in the panel, like Reports › Open, because it is a queue. + +## Data and display + +- Account status always shows a badge, Not linked included (Not linked neutral, Invite sent brand, Linked success, + Needs review warning). Expired invites show a danger "Expired {date}" badge in Linking and on the record. +- Member numbers are 4-digit DM Mono (`0412`); person IDs are `P-0142` in DM Mono. +- Member status tones: Active success, Suspended danger, Expired warning, Former neutral. Fee: Paid success, Receipt + uploaded brand, Due warning, Unpaid danger. +- Census answers: Continues success, Leaving warning, No answer neutral. Source text: "Imported from Forms" / + "Dashboard". +- 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. + +## Per page + +- **Overview**: personal email is the secondary line. Admin is a state badge (Active / Former); Capo Admin a scope chip. + Last census reads "year · answer" and falls back to the last answered year. Row actions: Send invite (only while not + linked; linked rows keep an empty slot so icons align) and ⋮ (Open record, Link account, Add to team). Columns hide + by priority: Capo Admin, then Last census, then Teams. +- **Person record**: two columns from 1100px (1.55 : 1): left Profile, Admin, Teams, Census history; right Account, + Member, Consents, HR notes. Activity full width below. Discrepancies are warning `InlineAlert`s above the cards, each + with the action that fixes it. Account actions follow the state: Not linked → Send invite + Link account; Invite sent + → Resend invite + Link account; Linked → Unlink. Header outline action "Add role". Capo Admins and Team Leads do not + open full records. +- **Member register**: status is the scope control (All / Active / Suspended / Expired / Former), fee a filter. Number + is the first, sortable column; on mobile the name leads the card. Change member number is a confirmation with the + field and the consequence (the Microsoft 365 account's member ID changes too). Export confirms first. HR sees no + Actions column and no header actions. +- **Admins**: course is the name's secondary line. Groups, Capo Admin of reference, Member and Census always show; + 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 rest in ⋮. +- **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, + 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 + (primary), Send reminders (outline), Close campaign in ⋮. Segments: Expected (all), Answered, Continuing, Leaving, No + answer. Reminders skip people reminded in the last 3 days, and the confirmation says how many. No "New campaign" + while one is open. +- **Import wizard**: lg dialog (bottom sheet on mobile), five steps Upload → Review → Duplicates → Telegram → Confirm; + steps without work are skipped. Review: counts then tabs (New people, Updates, Possible duplicates, Invalid rows, + Discrepancies, plus Already imported on a re-import). Invalid rows are skipped with a reason. Duplicates one pair at a + time, side by side, differences highlighted. Confirm states that nothing is deleted and that "No" answers end the role + with its reason. The file is parsed by the backend; the dashboard uploads it and renders the preview it returns. +- **Linking**: segmented scope Needs review (queue, Dismiss ghost / Resolve outline) and Pending invites (table, Resend / + Revoke). Link account dialog: search, suggested matches with reason badge, required reason, inline conflict error that + keeps the dialog open. +- **Capo Admin**: context line names the scope ("Ingegneria Informatica · 3 groups"). Cards My groups (warning badge on + groups without admins) and Requests to join (Decline ghost / Approve outline) above the Admins of my groups table + (phone, Telegram, Open in Telegram / Open in WhatsApp; no personal email). +- **My area**: open census first while unanswered, pre-filled from last year (year advanced by one). My roles, People to + contact (Capo Admin, Team Lead, HR), My profile with Request a correction (no direct edits: HR owns records), My + consents with Revoke on photo/video. Team roster: name, role, Telegram, PoliNetwork email only. First sign-in: "Is + this you?" card with the matched record and the match reason. No record: empty state with Ask to be added, then a + "Waiting for HR" state. + +## Roles to permissions + +| Viewer | Permissions (IdP) | Sees | +| ----------- | ----------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | +| Direttivo | `admin:access`, `people:read`, `people:write`, `people:members:read`, `people:members:write`, `people:appoint`, `people:self` | Everything | +| HR | `admin:access`, `people:read`, `people:write`, `people:members:read`, `people:self` | All People sections; member register read-only | +| Capo Admin | `people:self` (+ Capo Admin scope on their record) | Telegram › Capo Admin, My area | +| Team Lead | `people:self` (+ Lead role on their record) | People › Teams (their team, full fields), My area | +| Team member | `people:self` | My area with their team roster | +| Admin only | `people:self` | My area | + +Contacts (phone, personal email) need `people:read`, or Capo Admin scope / Team Lead role over that person. HR notes and +full census answers need `people:read`. The backend enforces all of this again; the dashboard only hides. diff --git a/docs/people/plan.md b/docs/people/plan.md new file mode 100644 index 00000000..a344fb5f --- /dev/null +++ b/docs/people/plan.md @@ -0,0 +1,121 @@ +# People area: implementation plan + +Work happens on branch `claude/eager-mayer-bsom7v` in one pull request. Tick a box in the same commit that finishes it, +and add a line to the progress log at the bottom. Each phase ends green on `pnpm check`, `pnpm typecheck`, `pnpm test` +and `pnpm build`, and is committed and pushed before the next one starts (Conventional Commits, see `CLAUDE.md`). + +Read first: `CLAUDE.md`, `docs/design.md`, [`decisions.md`](decisions.md), [`contract.md`](contract.md). Look at the +prototype for structure and copy only (see decisions: it has visual errors; build from the primitives). + +## Architecture + +``` +src/features/people/ + people.contract.ts zod schemas + types from contract.md, TEAM_FIELDS, labels and tones + people.fixtures.server.ts seeded in-memory data (port of buildData() in the prototype), mutated by agent-mode calls + people.source.server.ts peopleSource(context): fixtures in agent mode, else context.backend.people (typed via contract) + people.functions.ts read server functions (GET) per page + people-write.functions.ts mutations (POST), each with its exact permission middleware + me.functions.ts My area reads and self-service mutations (people:self) + capo.functions.ts Capo Admin reads and approve/decline + format.ts display helpers (census parts, role names, initials, team field text) + overview-page.tsx, person-page.tsx, members-page.tsx, admins-page.tsx, teams-page.tsx, team-page.tsx, + census-page.tsx, campaign-page.tsx, import-dialog.tsx, linking-page.tsx, link-account-dialog.tsx, + resolve-dialog.tsx, capo-page.tsx, my-area-page.tsx, dialogs/*.tsx +src/routes/dashboard/people/ index (overview), $personId, members, admins, teams/index, teams/$teamId, + census/index, census/$campaignId, linking +src/routes/dashboard/telegram/capo.tsx +src/routes/dashboard/account/ me.tsx (My area), index.tsx (today's Account page moved here) +``` + +- The backend client type has no `people` router, so `people.source.server.ts` declares the contract's router shape and + narrows `context.backend` to it in one place, with a safety comment (lint rule + `anti-slop/require-safety-comment-for-type-assertion`). Nothing else touches the cast. +- Agent mode: `loadAccess` keeps returning every permission. Add a development-only `AGENT_PERMISSIONS` env (comma + list) and `AGENT_PERSON` (person ID for My area) so restricted views can be previewed; document both in README. +- Access: `loadAccess` returns `authorized` when the user has `admin:access` **or** `people:self`. The route context + carries permissions as today; `useCan` and nav filtering hide everything else. `adminMiddleware` stays as is for + existing services; People reads use a new `peopleReadMiddleware` (`people:read`), and self-service functions use + `peopleSelfMiddleware` (`people:self`). Overview (`/dashboard`) and every existing route must still require + `admin:access`: add a guard so a `people:self`-only user is redirected to `/dashboard/account/me`. +- Tests: list every new `*.functions.ts` and each POST function with its consumer file, and every `useCan` file with + its permission, in `tests/server-security.test.mjs` (the failing test names what to add). + +## Phases + +### 0. Handoff and PR + +- [x] `docs/people/` (brief, decisions, contract, plan, prototype), draft PR opened + +### 1. Foundation + +- [ ] Permissions: add the six `people:*` permissions; middlewares `peopleReadMiddleware`, `peopleWriteMiddleware`, + `peopleMembersReadMiddleware`, `peopleMembersWriteMiddleware`, `peopleAppointMiddleware`, `peopleSelfMiddleware` +- [ ] Access: `people:self` admits to the dashboard; guard existing routes for `admin:access`; `AGENT_PERMISSIONS` / + `AGENT_PERSON` +- [ ] `people.contract.ts`, `people.fixtures.server.ts` (148 people, same distributions as the prototype), + `people.source.server.ts` +- [ ] Nav: People service and sections, Telegram › Capo Admin, Account as a two-section service (My area, Sign-in & + security); `PageMatch` kinds and `matchPath`; rail/panel/pills/bottom nav filtering by permission and by record + role (Capo Admin, Team Lead) +- [ ] Route files with `pendingComponent` skeletons for every page +- [ ] Tests registry updated; all four checks green + +### 2. Overview and person record + +- [ ] Overview: DataTable with columns, filters (role, team, campus, account, census), search, count, pagination, + Send invite + ⋮ row actions, empty states +- [ ] Add person / Edit profile dialog (`FormDialog` lg) +- [ ] Person record: RecordHeader, discrepancy alerts, all cards, Activity; Send / resend invite, revoke, unlink + confirmations; HR notes dialog; census answers dialog + +### 3. Member register + +- [ ] Table, status segments, fee filter, sort by number/name/admitted +- [ ] Add member, Change status, Change member number, Export (confirm then download) + +### 4. Admins + +- [ ] Table with priority columns, Active/Former, campus/year filters, the four quick filters +- [ ] End admin role; set Capo Admin of reference; appoint Capo Admin (`people:appoint`) + +### 5. Teams + +- [ ] Index cards + all-members table +- [ ] Team page (Active/Former, team fields, contacts for Lead); Team Lead lands here from People › Teams +- [ ] Add to team, Edit team fields, End membership + +### 6. Census + +- [ ] Campaign list; campaign page with segments, reminders, close +- [ ] Import wizard: upload, review tabs, duplicates, Telegram suggestions, confirm; re-import "Already imported" + +### 7. Linking + +- [ ] Needs review queue with Dismiss / Resolve dialog per kind +- [ ] Pending invites with Resend / Revoke and expired badge +- [ ] Link account dialog with suggestions, required reason, inline conflict + +### 8. Capo Admin + +- [ ] My groups, Requests to join (approve/decline), Admins of my groups with Telegram/WhatsApp links + +### 9. My area + +- [ ] Linked: census card (confirm / leaving dialog), roles, contacts, profile + Request a correction, consents + + revoke, team roster +- [ ] First sign-in match ("Is this you?") and "This isn't me" state +- [ ] No record: Ask to be added dialog and waiting state + +### 10. Finish + +- [ ] Loading and empty states checked on every page; mobile (390px) and dark checked on every page in agent mode +- [ ] `docs/design.md` §8 page notes for People, Capo Admin, My area; README permissions table; `CLAUDE.md` + architecture lines +- [ ] PR description final, all checks green + +## Progress log + +Newest last. One line per commit or session: date, what was done, what is next. + +- 2026-10-14: phase 0 done (handoff docs, prototype copy, draft PR). Next: phase 1. diff --git a/docs/people/prototype/README.md b/docs/people/prototype/README.md new file mode 100644 index 00000000..beed3b6b --- /dev/null +++ b/docs/people/prototype/README.md @@ -0,0 +1,14 @@ +# Prototype source + +`prototype.dc.html` and `prototype.css` are the clickable prototype from the Design canvas +(). They run only inside that canvas (they need its `support.js` +runtime), so read them as reference: + +- `buildData()` in the script is the sample-data generator to port to `src/features/people/people.fixtures.server.ts` + (same seed, names, courses, groups, distributions and key people such as Giulia Rossi `P-0142`, Davide Russo + `P-0098`, Sara Galli `P-0034`, Matteo Conti `P-0115`). +- `VIEWS` shows what each role sees; `page*()` methods list each page's columns, filters, actions and copy; `dlgView()` + holds every dialog's copy and validation messages; `importView()` the import wizard. +- `prototype.css` is **not** to be ported: it is a stand-in for the repo's components and contains spacing errors. + +These files are excluded from `pnpm check` (see `vite.config.ts`). diff --git a/docs/people/prototype/prototype.css b/docs/people/prototype/prototype.css new file mode 100644 index 00000000..0bfed934 --- /dev/null +++ b/docs/people/prototype/prototype.css @@ -0,0 +1,545 @@ +/* PoliNetwork Admin — People prototype. Tokens copied from src/styles.css (--pn-*). */ +.pn { + --pn-brand-50: #f2f7fe; --pn-brand-100: #e2eeff; --pn-brand-200: #c5ddff; --pn-brand-300: #95c0fe; + --pn-brand-400: #619cf1; --pn-brand-500: #3578d7; --pn-brand-600: #1156ae; --pn-brand-700: #0a448e; + --pn-brand-800: #0a366f; --pn-brand-900: #0a2953; --pn-brand-950: #071a36; + --pn-bg: #f6f7f9; --pn-surface: #ffffff; --pn-surface-raised: #ffffff; --pn-nav: #ffffff; --pn-panel: #fbfcfd; + --pn-sunken: #f9fafc; --pn-nav-active: #e4e8ed; --pn-muted: #f2f4f7; + --pn-line: oklch(0.22 0.024 250 / 0.1); --pn-line-strong: oklch(0.22 0.024 250 / 0.18); + --pn-fg: #121b25; --pn-fg-muted: #58626c; --pn-fg-subtle: #727c86; + --pn-accent: #1156ae; --pn-accent-hover: #0a448e; --pn-accent-solid: #1156ae; --pn-accent-solid-hover: #0a448e; + --pn-accent-solid-fg: #ffffff; --pn-accent-soft: #f2f7fe; --pn-accent-soft-hover: #e2eeff; + --pn-hover: color-mix(in oklab, #3578d7 6%, #ffffff); + --pn-hover-line: color-mix(in oklab, #1156ae 40%, oklch(0.22 0.024 250 / 0.18)); + --pn-focus: #3578d7; + --pn-success-fg: #00642b; --pn-success-bg: #e8f9eb; --pn-success-solid: #007835; + --pn-warning-fg: #774500; --pn-warning-bg: #fff1e4; --pn-warning-solid: #8e5300; + --pn-danger-fg: #8c2d28; --pn-danger-bg: #ffefed; --pn-danger-solid: #a43b35; + --pn-info-fg: #0a448e; --pn-info-bg: #f2f7fe; + --pn-scrim: oklch(0.16 0.02 250 / 0.4); + --pn-shadow-float: 0 0 0 1px oklch(0.22 0.024 250 / 0.06), 0 2px 4px oklch(0.22 0.024 250 / 0.04), 0 8px 24px oklch(0.22 0.024 250 / 0.08); + --pn-shadow-modal: 0 0 0 1px oklch(0.22 0.024 250 / 0.06), 0 4px 8px oklch(0.22 0.024 250 / 0.04), 0 24px 48px oklch(0.22 0.024 250 / 0.12); + --pn-media-ring: inset 0 0 0 1px oklch(0 0 0 / 0.1); + --pn-ease-out: cubic-bezier(0.32, 0.72, 0, 1); + --pn-ease-in: cubic-bezier(0.4, 0, 1, 1); + --sans: "DM Sans", ui-sans-serif, system-ui, sans-serif; + --mono: "DM Mono", ui-monospace, monospace; + height: 100vh; display: flex; flex-direction: column; overflow: hidden; + font-family: var(--sans); font-size: 14px; line-height: 20px; color: var(--pn-fg); + background: #dfe2e7; -webkit-font-smoothing: antialiased; font-variant-numeric: tabular-nums; +} +.pn[data-theme="dark"] { + --pn-bg: #090f15; --pn-surface: #11181f; --pn-surface-raised: #192028; --pn-nav: #060b11; --pn-panel: #0b1218; + --pn-sunken: #0d141a; --pn-nav-active: oklch(1 0 0 / 0.08); --pn-muted: #1b222a; + --pn-line: #262f38; --pn-line-strong: #353e47; + --pn-fg: #f2f4f7; --pn-fg-muted: #9da6ae; --pn-fg-subtle: #727c86; + --pn-accent: #95c0fe; --pn-accent-hover: #c5ddff; --pn-accent-solid: #619cf1; --pn-accent-solid-hover: #95c0fe; + --pn-accent-solid-fg: #060e16; --pn-accent-soft: #0a2953; --pn-accent-soft-hover: #071a36; + --pn-hover: color-mix(in oklab, #95c0fe 7%, #192028); + --pn-hover-line: color-mix(in oklab, #95c0fe 35%, #353e47); + --pn-focus: #95c0fe; + --pn-success-fg: #83d494; --pn-success-bg: #122d19; --pn-success-solid: #83d494; + --pn-warning-fg: #f3ad66; --pn-warning-bg: #372108; --pn-warning-solid: #f3ad66; + --pn-danger-fg: #ffa196; --pn-danger-bg: #3b1c19; --pn-danger-solid: #c9524a; + --pn-info-fg: #95c0fe; --pn-info-bg: #0a2953; + --pn-scrim: oklch(0 0 0 / 0.6); + --pn-shadow-float: 0 0 0 1px #353e47, 0 8px 24px oklch(0 0 0 / 0.5); + --pn-shadow-modal: 0 0 0 1px #353e47, 0 24px 48px oklch(0 0 0 / 0.6); + --pn-media-ring: inset 0 0 0 1px oklch(1 0 0 / 0.1); + background: #2a2f36; color-scheme: dark; +} +.pn *, .pn *::before, .pn *::after { box-sizing: border-box; } +.pn button, .pn input, .pn select, .pn textarea { font: inherit; color: inherit; } +.pn button { cursor: pointer; background: none; border: 0; padding: 0; margin: 0; } +.pn h1, .pn h2, .pn h3, .pn p { margin: 0; } +.pn h1, .pn h2, .pn h3 { text-wrap: balance; } +.pn p { text-wrap: pretty; } +.pn a { color: var(--pn-accent); text-decoration: none; } +.pn a:hover { color: var(--pn-accent-hover); text-decoration: underline; } +.pn :focus-visible { outline: 2px solid var(--pn-focus); outline-offset: 2px; } +.pn ::selection { background: var(--pn-brand-200); } +.pn .mono { font-family: var(--mono); font-size: 13px; letter-spacing: -0.01em; } +.pn .muted { color: var(--pn-fg-muted); } +.pn .subtle { color: var(--pn-fg-subtle); } +.pn .strong { font-weight: 500; } +.pn .num { text-align: right; } +.pn .nowrap { white-space: nowrap; } +.pn .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } +.pn .ic { width: 18px; height: 18px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.75; stroke-linecap: round; stroke-linejoin: round; } +.pn .ic.s16 { width: 16px; height: 16px; } +.pn .ic.s14 { width: 14px; height: 14px; } +.pn .ic.s20 { width: 20px; height: 20px; } +.pn .glyph { width: 16px; height: 16px; flex: none; } +.pn .glyph.s20 { width: 20px; height: 20px; } +.pn .glyph.s22 { width: 22px; height: 22px; } + +/* Prototype bar: not part of the product. */ +.proto { flex: none; display: flex; align-items: center; gap: 16px; flex-wrap: wrap; padding: 8px 16px; min-height: 52px; + background: #15181c; color: #d9dde3; font: 500 12px/16px var(--sans); border-bottom: 2px dashed #e0a43a; } +.proto .tag { display: inline-flex; align-items: center; gap: 6px; height: 24px; padding: 0 8px; border-radius: 6px; background: #e0a43a; color: #1a1406; font-weight: 600; } +.proto label { display: inline-flex; align-items: center; gap: 8px; color: #9aa3ad; } +.proto select { height: 32px; border-radius: 6px; border: 1px solid #3a414a; background: #22272e; color: #f0f2f5; padding: 0 8px; font: 500 13px var(--sans); } +.proto .pseg { display: inline-flex; border: 1px solid #3a414a; border-radius: 6px; overflow: hidden; } +.proto .pseg button { height: 30px; padding: 0 10px; color: #c3c9d1; font: 500 12px var(--sans); } +.proto .pseg button.on { background: #e0a43a; color: #1a1406; } +.proto .grow { flex: 1; } + +.stage { flex: 1; min-height: 0; display: flex; justify-content: center; align-items: stretch; } +.pn[data-device="mobile"] .stage { align-items: center; padding: 16px; } +.app { container: app / inline-size; position: relative; flex: 1; min-width: 0; overflow: hidden; background: var(--pn-bg); display: flex; } +.pn[data-device="mobile"] .app { flex: none; width: 390px; height: min(844px, 100%); border-radius: 28px; box-shadow: 0 0 0 10px #15181c, 0 30px 60px rgb(0 0 0 / 0.3); } + +/* Shell */ +.shell { display: flex; width: 100%; height: 100%; } +.rail { width: 64px; flex: none; background: var(--pn-nav); border-right: 1px solid var(--pn-line); display: flex; flex-direction: column; align-items: center; padding: 12px 0; gap: 4px; } +.rail-sep { width: 28px; height: 1px; background: var(--pn-line); margin: 6px 0; } +.rail-grow { flex: 1; } +.ri { position: relative; width: 44px; height: 44px; border-radius: 8px; display: grid; place-items: center; color: var(--pn-fg-muted); transition: background-color 120ms, color 120ms; } +.ri:hover { background: var(--pn-hover); color: var(--pn-fg); } +.ri.on { background: var(--pn-nav-active); color: var(--pn-fg); } +.ri.on::before { content: ""; position: absolute; left: -10px; top: 10px; bottom: 10px; width: 3px; border-radius: 0 3px 3px 0; background: var(--pn-accent); } +.ri .dot { position: absolute; top: 9px; right: 9px; width: 7px; height: 7px; border-radius: 50%; background: var(--pn-accent); box-shadow: 0 0 0 2px var(--pn-nav); } +.ri .tip { position: absolute; left: 52px; top: 50%; transform: translateY(-50%); white-space: nowrap; padding: 4px 8px; border-radius: 8px; background: var(--pn-surface-raised); color: var(--pn-fg); border: 1px solid var(--pn-line); box-shadow: var(--pn-shadow-float); font-size: 12px; pointer-events: none; opacity: 0; z-index: 30; } +.ri:hover .tip { opacity: 1; transition: opacity 120ms 400ms; } +.logo { width: 32px; height: 32px; border-radius: 50%; display: block; } +.me-av { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; background: var(--pn-accent-solid); color: var(--pn-accent-solid-fg); font-size: 11px; font-weight: 600; } + +.panel { width: 240px; flex: none; background: var(--pn-panel); border-right: 1px solid var(--pn-line); display: flex; flex-direction: column; } +.panel-h { height: 64px; display: flex; align-items: center; gap: 10px; padding: 0 18px; font-size: 15px; font-weight: 600; border-bottom: 1px solid var(--pn-line); } +.panel-h .ic { color: var(--pn-fg-muted); } +.panel-list { padding: 8px; display: flex; flex-direction: column; gap: 2px; } +.pi { position: relative; height: 38px; border-radius: 8px; display: flex; align-items: center; gap: 10px; padding: 0 10px; color: var(--pn-fg); text-align: left; width: 100%; transition: background-color 120ms; } +.pi .ic { color: var(--pn-fg-muted); } +.pi:hover { background: var(--pn-hover); } +.pi.on, .pi.anc { background: var(--pn-nav-active); } +.pi.on .ic, .pi.anc .ic { color: var(--pn-accent); } +.pi.on::before { content: ""; position: absolute; left: -8px; top: 9px; bottom: 9px; width: 3px; border-radius: 0 3px 3px 0; background: var(--pn-accent); } +.pi .grow { flex: 1; } +.pill-count { min-width: 20px; height: 20px; padding: 0 6px; border-radius: 999px; background: var(--pn-accent-solid); color: var(--pn-accent-solid-fg); font: 500 11px/20px var(--mono); text-align: center; } + +.col { flex: 1; min-width: 0; display: flex; flex-direction: column; position: relative; } +.topbar, .bnav, .pills { display: none; } +.scroll { flex: 1; min-height: 0; overflow-y: auto; scrollbar-width: thin; scrollbar-color: var(--pn-line-strong) transparent; } +.page { max-width: 1280px; margin: 0 auto; padding: 24px 32px 56px; display: flex; flex-direction: column; gap: 20px; } + +.topbar { height: 56px; flex: none; align-items: center; gap: 8px; padding: 0 8px 0 12px; background: var(--pn-nav); border-bottom: 1px solid var(--pn-line); } +.topbar .t { flex: 1; font-size: 16px; font-weight: 600; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.pills { gap: 8px; overflow-x: auto; padding: 12px 16px 0; scrollbar-width: none; } +.pill { height: 36px; flex: none; display: inline-flex; align-items: center; gap: 6px; padding: 0 12px; border-radius: 999px; border: 1px solid var(--pn-line); background: var(--pn-surface); white-space: nowrap; } +.pill .ic { color: var(--pn-fg-muted); width: 16px; height: 16px; } +.pill.on { background: var(--pn-accent-soft); border-color: transparent; color: var(--pn-accent); } +.pill.on .ic { color: var(--pn-accent); } +.bnav { height: 64px; flex: none; background: var(--pn-nav); border-top: 1px solid var(--pn-line); } +.bn { position: relative; flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; font-size: 11px; color: var(--pn-fg-muted); min-width: 0; } +.bn.on { color: var(--pn-accent); } +.bn.on::before { content: ""; position: absolute; top: 0; left: 25%; right: 25%; height: 2px; border-radius: 0 0 2px 2px; background: var(--pn-accent); } +.bn .dot { position: absolute; top: 10px; left: calc(50% + 6px); width: 7px; height: 7px; border-radius: 50%; background: var(--pn-accent); } + +@container app (max-width: 1099px) { .panel { width: 200px; } } +@container app (max-width: 759px) { + .rail, .panel { display: none; } + .topbar, .bnav { display: flex; } + .pills { display: flex; } + .page { padding: 16px 16px 32px; gap: 16px; } +} + +/* Page header */ +.ph { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; } +.ph-t { display: flex; align-items: center; gap: 12px; min-width: 0; } +.ph-tile { width: 44px; height: 44px; border-radius: 10px; border: 1px solid var(--pn-line); background: var(--pn-surface); display: grid; place-items: center; color: var(--pn-fg-muted); flex: none; } +.ph h1 { font-size: 24px; line-height: 32px; font-weight: 600; letter-spacing: -0.015em; } +.ph .ctx { color: var(--pn-fg-muted); margin-top: 2px; } +.ph-acts { display: flex; gap: 8px; flex: none; } +.crumbs { display: flex; align-items: center; gap: 8px; min-height: 40px; font-size: 14px; } +.crumbs button.lnk { color: var(--pn-fg-muted); } +.crumbs button.lnk:hover { color: var(--pn-accent); } +.crumbs .sep { color: var(--pn-fg-subtle); } +@container app (max-width: 759px) { + .ph { flex-direction: column; gap: 12px; } + .ph h1 { font-size: 22px; line-height: 28px; } + .ph-acts { width: 100%; } + .ph-acts > * { flex: 1; } +} + +/* Buttons */ +.btn { height: 40px; padding: 0 14px; border-radius: 8px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; font-weight: 500; white-space: nowrap; border: 1px solid transparent; transition: background-color 120ms, color 120ms, border-color 120ms, transform 120ms; } +.btn:active { transform: translateY(1px); } +.btn.sm { height: 36px; padding: 0 12px; } +.btn.primary { background: var(--pn-accent-solid); color: var(--pn-accent-solid-fg); } +.btn.primary:hover { background: var(--pn-accent-solid-hover); } +.btn.outline { background: var(--pn-surface); border-color: var(--pn-line-strong); } +.btn.outline:hover { background: var(--pn-hover); border-color: var(--pn-hover-line); } +.btn.ghost { color: var(--pn-fg); } +.btn.ghost:hover { background: var(--pn-hover); color: var(--pn-accent); } +.btn.dtext { color: var(--pn-danger-fg); } +.btn.dtext:hover { color: var(--pn-accent); } +.btn.danger { background: var(--pn-danger-solid); color: #fff; } +.btn.danger:hover { filter: brightness(0.92); } +.pn[data-theme="dark"] .btn.danger { color: #fff; } +.btn .ic { width: 16px; height: 16px; } +.btn.full { width: 100%; } +.ib { width: 36px; height: 36px; border-radius: 8px; display: inline-grid; place-items: center; color: var(--pn-fg-muted); flex: none; transition: background-color 120ms, color 120ms; position: relative; } +.ib:hover { background: var(--pn-accent-soft); color: var(--pn-accent); } +.ib.boxed { border: 1px solid var(--pn-line-strong); background: var(--pn-surface); } +.ib.danger { color: var(--pn-danger-fg); } +.ib.danger:hover { color: var(--pn-accent); } +.ib.ghostslot { visibility: hidden; } +.ib .tipb { position: absolute; top: calc(100% + 6px); left: 50%; transform: translateX(-50%); white-space: nowrap; padding: 4px 8px; border-radius: 8px; background: var(--pn-surface-raised); color: var(--pn-fg); border: 1px solid var(--pn-line); box-shadow: var(--pn-shadow-float); font-size: 12px; line-height: 16px; pointer-events: none; opacity: 0; z-index: 35; } +.ib:hover .tipb { opacity: 1; transition: opacity 100ms 100ms; } +.kbd { display: inline-grid; place-items: center; min-width: 20px; height: 20px; padding: 0 5px; border-radius: 4px; border: 1px solid var(--pn-line-strong); font: 500 11px var(--mono); color: var(--pn-fg-muted); } + +/* Toolbar */ +.tb { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.search { position: relative; flex: 0 1 340px; min-width: 220px; } +.search .ic { position: absolute; left: 12px; top: 11px; color: var(--pn-fg-muted); width: 18px; height: 18px; } +.search input { width: 100%; height: 40px; border: 1px solid var(--pn-line-strong); border-radius: 8px; background: var(--pn-surface); padding: 0 40px 0 38px; outline: none; } +.search input::placeholder { color: var(--pn-fg-subtle); } +.search input:focus { border-color: var(--pn-focus); box-shadow: 0 0 0 3px color-mix(in oklab, var(--pn-focus) 25%, transparent); } +.search .kbd { position: absolute; right: 10px; top: 10px; } +.fwrap { position: relative; display: inline-flex; } +.fbtn { gap: 6px; padding: 0 10px 0 12px; } +.fbtn .ic { color: var(--pn-fg-muted); } +.fbtn.set { border-color: var(--pn-hover-line); } +.fbtn .fv { color: var(--pn-accent); } +.qf { height: 40px; padding: 0 12px; border-radius: 999px; border: 1px solid var(--pn-line-strong); background: var(--pn-surface); display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; transition: background-color 120ms, border-color 120ms; } +.qf:hover { background: var(--pn-hover); border-color: var(--pn-hover-line); } +.qf.on { background: var(--pn-accent-soft); border-color: transparent; color: var(--pn-accent); } +.qf .n { font: 500 12px var(--mono); color: var(--pn-fg-muted); } +.qf.on .n { color: var(--pn-accent); } +.count { font-size: 13px; color: var(--pn-fg-muted); white-space: nowrap; margin-left: 4px; } +.tb-gap { flex: 1; } +.seg { display: inline-flex; height: 40px; padding: 3px; background: var(--pn-muted); border-radius: 8px; gap: 2px; flex: none; max-width: 100%; overflow-x: auto; scrollbar-width: none; } +.seg button { height: 34px; padding: 0 12px; border-radius: 6px; display: inline-flex; align-items: center; gap: 8px; white-space: nowrap; color: var(--pn-fg-muted); border: 1px solid transparent; } +.seg button:hover { color: var(--pn-fg); } +.seg button.on { background: var(--pn-surface); color: var(--pn-fg); border-color: var(--pn-line); } +.seg .n { font: 500 12px var(--mono); color: var(--pn-fg-muted); } +@container app (max-width: 759px) { + .search { flex: 1 1 100%; min-width: 0; } + .search .kbd { display: none; } + .search input { font-size: 16px; } +} + +/* Menus and popovers */ +.catch { position: fixed; inset: 0; z-index: 39; } +.menu { text-align: left; display: block; position: absolute; top: calc(100% + 4px); left: 0; z-index: 40; min-width: 220px; max-height: 320px; overflow-y: auto; padding: 4px; background: var(--pn-surface-raised); border-radius: 10px; box-shadow: var(--pn-shadow-float); animation: floatIn 160ms var(--pn-ease-out); transform-origin: top left; } +.menu.right { left: auto; right: 0; transform-origin: top right; } +.mi { width: 100%; height: 36px; padding: 0 10px; border-radius: 6px; display: flex; align-items: center; gap: 10px; text-align: left; white-space: nowrap; } +.mi:hover { background: var(--pn-hover); } +.mi .ic { color: var(--pn-fg-muted); width: 16px; height: 16px; } +.mi.danger { color: var(--pn-danger-fg); } +.mi.danger .ic { color: var(--pn-danger-fg); } +.mi .chk { visibility: hidden; color: var(--pn-accent); } +.mi .chk.on { visibility: visible; } +.mi .grow { flex: 1; } +.mi .n { font: 500 12px var(--mono); color: var(--pn-fg-muted); } +.msep { height: 1px; background: var(--pn-line); margin: 4px 0; } +@keyframes floatIn { from { opacity: 0; transform: scale(0.97); } to { opacity: 1; transform: none; } } + +/* Tables */ +.tsurf { container: tbl / inline-size; background: var(--pn-surface); border: 1px solid var(--pn-line); border-radius: 12px; } +.tsurf table { width: 100%; border-collapse: separate; border-spacing: 0; table-layout: fixed; } +.tsurf th { position: sticky; top: 0; z-index: 2; height: 42px; padding: 0 12px; text-align: left; font-size: 12px; font-weight: 500; color: var(--pn-fg-muted); border-bottom: 1px solid var(--pn-line); white-space: nowrap; background: var(--pn-surface); } +.tsurf thead tr th:first-child { border-top-left-radius: 12px; padding-left: 16px; } +.tsurf thead tr th:last-child { border-top-right-radius: 12px; } +.tsurf th.num, .tsurf th.act { text-align: right; } +.tsurf th .sortb { display: inline-flex; align-items: center; gap: 4px; height: 28px; margin: 0 -6px; padding: 0 6px; border-radius: 6px; color: inherit; font-weight: 500; } +.tsurf th .sortb:hover { background: var(--pn-hover); color: var(--pn-fg); } +.tsurf th .sortb .ic { width: 14px; height: 14px; } +.tsurf th .sortb .ic.idle { opacity: 0; } +.tsurf th .sortb:hover .ic.idle { opacity: 1; } +.tsurf td { height: 52px; padding: 0 12px; border-bottom: 1px solid var(--pn-line); vertical-align: middle; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; transition: background-color 120ms; } +.tsurf td:first-child { padding-left: 16px; } +.tsurf td.act { overflow: visible; text-align: right; padding-right: 8px; } +.tsurf td.act .acts { display: inline-flex; gap: 2px; align-items: center; justify-content: flex-end; } +.tsurf td.wrap { white-space: normal; } +.tsurf tbody tr:last-child td { border-bottom: 0; } +.tsurf tbody tr.click { cursor: pointer; } +.tsurf tbody tr.click:hover td, .tsurf tbody tr.hov:hover td { background: var(--pn-accent-soft); } +.tsurf tbody tr.arch td { color: var(--pn-fg-muted); } +.tsurf tbody tr:last-child td:first-child { border-bottom-left-radius: 12px; } +.tsurf tbody tr:last-child td:last-child { border-bottom-right-radius: 12px; } +.tsurf.hasfoot tbody tr:last-child td { border-bottom: 1px solid var(--pn-line); border-radius: 0; } +.vsep { display: inline-block; width: 1px; height: 20px; background: var(--pn-line); margin: 0 4px; vertical-align: middle; } +.who { display: flex; align-items: center; gap: 10px; min-width: 0; } +.who .tx { min-width: 0; display: flex; flex-direction: column; } +.who .nm { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.who .sub { font-size: 12px; line-height: 16px; color: var(--pn-fg-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.av { width: 32px; height: 32px; border-radius: 50%; flex: none; display: grid; place-items: center; background: var(--pn-muted); color: var(--pn-fg-muted); font-size: 11px; font-weight: 500; letter-spacing: -0.01em; box-shadow: var(--pn-media-ring); } +.av.s24 { width: 24px; height: 24px; font-size: 10px; } +.av.s40 { width: 40px; height: 40px; font-size: 13px; } +.av.s64 { width: 64px; height: 64px; font-size: 20px; } +.av.arch { opacity: 0.6; } +.tfoot { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 12px 8px 16px; min-height: 52px; font-size: 13px; color: var(--pn-fg-muted); flex-wrap: wrap; } +.tfoot .pg { display: flex; align-items: center; gap: 2px; } +.tfoot .pgb { min-width: 32px; height: 32px; border-radius: 8px; display: inline-grid; place-items: center; font: 500 13px var(--sans); color: var(--pn-fg); padding: 0 6px; } +.tfoot .pgb:hover { background: var(--pn-hover); } +.tfoot .pgb.on { background: var(--pn-accent-soft); color: var(--pn-accent); } +.tfoot .pgb.gap { color: var(--pn-fg-subtle); pointer-events: none; } +.tfoot .rpp { display: flex; align-items: center; gap: 8px; } +.tfoot select { height: 32px; border-radius: 8px; border: 1px solid var(--pn-line-strong); background: var(--pn-surface); padding: 0 8px; } +.tfoot .pgm { display: none; } + +/* column widths and priorities */ +.w60 { width: 60px; } .w80 { width: 80px; } .w88 { width: 88px; } .w168 { width: 168px; } .w72 { width: 72px; } .w84 { width: 84px; } .w96 { width: 96px; } .w104 { width: 104px; } .w112 { width: 112px; } .w120 { width: 120px; } +.w128 { width: 128px; } .w136 { width: 136px; } .w144 { width: 144px; } .w152 { width: 152px; } .w160 { width: 160px; } .w176 { width: 176px; } +.w184 { width: 184px; } .w200 { width: 200px; } .w220 { width: 220px; } .w240 { width: 240px; } .w260 { width: 260px; } .w280 { width: 280px; } +@container tbl (max-width: 1500px) { .p6 { display: none; } } +@container tbl (max-width: 1240px) { .p5 { display: none; } } +@container tbl (max-width: 1060px) { .p4 { display: none; } } +@container tbl (max-width: 900px) { .p3 { display: none; } } +@container tbl (max-width: 700px) { .p2 { display: none; } } + +@container app (max-width: 759px) { + .tsurf { container-type: normal; } + .tsurf table, .tsurf tbody, .tsurf tr, .tsurf td { display: block; width: auto; } + .tsurf thead { display: none; } + .tsurf tbody tr { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; padding: 12px 16px; border-bottom: 1px solid var(--pn-line); } + .tsurf tbody tr:last-child { border-bottom: 0; } + .tsurf td, .tsurf td:first-child { height: auto; padding: 0; border: 0 !important; font-size: 12px; line-height: 16px; color: var(--pn-fg-muted); white-space: nowrap; overflow: visible; } + .tsurf td.p2, .tsurf td.p3, .tsurf td.p4, .tsurf td.p5, .tsurf td.p6 { display: block; } + .tsurf td.c1 { order: -1; } + .tsurf td.c1 { flex-basis: 100%; font-size: 14px; color: var(--pn-fg); margin-bottom: 2px; } + .tsurf td.mfull { flex-basis: 100%; } + .tsurf td.mhide { display: none !important; } + .tsurf td.act { flex-basis: 100%; text-align: left; padding: 4px 0 0; margin-left: -8px; } + .tsurf td.act .acts { justify-content: flex-start; } + .tsurf td .badge, .tsurf td .chip { font-size: 11px; height: 20px; } + .tsurf td .mono { font-size: 12px; } + .tsurf tbody tr.click:hover td { background: transparent; } + .tsurf tbody tr.click:hover { background: var(--pn-accent-soft); } + .tsurf .chips { flex-wrap: wrap; } + .tfoot .pg, .tfoot .rpp { display: none; } + .tfoot .pgm { display: flex; align-items: center; gap: 4px; } + .tfoot .pgm .pgb { min-width: 36px; height: 36px; } +} + +/* Badges and chips */ +.badge { display: inline-flex; align-items: center; gap: 6px; height: 22px; padding: 0 8px; border-radius: 999px; font-size: 12px; font-weight: 500; white-space: nowrap; flex: none; vertical-align: middle; } +.badge::before { content: ""; width: 6px; height: 6px; border-radius: 50%; } +.badge.t-neutral { background: var(--pn-muted); color: var(--pn-fg-muted); } +.badge.t-neutral::before { background: var(--pn-fg-muted); } +.badge.t-brand { background: var(--pn-info-bg); color: var(--pn-info-fg); } +.badge.t-brand::before { background: var(--pn-accent); } +.badge.t-success { background: var(--pn-success-bg); color: var(--pn-success-fg); } +.badge.t-success::before { background: var(--pn-success-solid); } +.badge.t-warning { background: var(--pn-warning-bg); color: var(--pn-warning-fg); } +.badge.t-warning::before { background: var(--pn-warning-solid); } +.badge.t-danger { background: var(--pn-danger-bg); color: var(--pn-danger-fg); } +.badge.t-danger::before { background: var(--pn-danger-solid); } +.chip { display: inline-flex; align-items: center; gap: 4px; height: 22px; padding: 0 8px; border-radius: 999px; border: 1px solid var(--pn-line); font-size: 12px; font-weight: 500; white-space: nowrap; flex: none; color: var(--pn-fg); vertical-align: middle; max-width: 100%; overflow: hidden; text-overflow: ellipsis; } +.chip.mono { font-family: var(--mono); font-size: 12px; } +.chip.cnt { border-color: transparent; background: var(--pn-muted); color: var(--pn-fg-muted); font-family: var(--mono); position: relative; overflow: visible; } +.chip.cnt .pop { display: none; position: absolute; top: calc(100% + 6px); left: 0; z-index: 30; min-width: 160px; padding: 8px 10px; background: var(--pn-surface-raised); color: var(--pn-fg); border-radius: 12px; box-shadow: var(--pn-shadow-float); font: 400 13px/20px var(--sans); white-space: nowrap; flex-direction: column; } +.chip.cnt:hover .pop { display: flex; } +.chip .glyph { width: 14px; height: 14px; } +.chips { display: flex; gap: 4px; align-items: center; min-width: 0; } +.dash { color: var(--pn-fg-subtle); } + +/* Cards */ +.card { background: var(--pn-surface); border: 1px solid var(--pn-line); border-radius: 12px; min-width: 0; } +.card-h { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 12px 0 16px; min-height: 48px; } +.card-h h2 { font-size: 15px; line-height: 22px; font-weight: 600; display: flex; align-items: center; gap: 8px; } +.card-h .acts { display: flex; gap: 4px; align-items: center; } +.card-b { padding: 12px 16px 16px; display: flex; flex-direction: column; gap: 12px; } +.card-b.flush { padding: 8px 0 0; } +.kv { display: flex; flex-direction: column; gap: 10px; } +.kvr { display: grid; grid-template-columns: minmax(120px, 184px) minmax(0, 1fr); gap: 16px; align-items: center; min-height: 22px; } +.kk { color: var(--pn-fg-muted); font-size: 13px; } +.vv { min-width: 0; overflow-wrap: anywhere; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } +@container app (max-width: 759px) { .kvr { grid-template-columns: minmax(0, 1fr); gap: 2px; } } +.rowh { padding: 14px 16px 6px; font-size: 12px; font-weight: 500; color: var(--pn-fg-muted); border-top: 1px solid var(--pn-line); } +.rowic { width: 32px; height: 32px; border-radius: 8px; background: var(--pn-muted); color: var(--pn-fg-muted); display: grid; place-items: center; flex: none; } +.rowic .ic { width: 16px; height: 16px; } +.rt { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; min-width: 0; } +.rt2 { font-size: 13px; line-height: 20px; margin-top: 2px; overflow-wrap: anywhere; } +.card-empty { padding: 4px 16px 16px; color: var(--pn-fg-muted); font-size: 13px; } +.card-f { display: flex; gap: 8px; flex-wrap: wrap; padding: 12px 16px 16px; border-top: 1px solid var(--pn-line); } +.card-txt { padding: 4px 16px 16px; white-space: pre-line; } +.grid11 { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; align-items: start; } +@container app (max-width: 1099px) { .grid11 { grid-template-columns: minmax(0, 1fr); } } +.rh-tile { width: 64px; height: 64px; border-radius: 14px; border: 1px solid var(--pn-line); background: var(--pn-surface); display: grid; place-items: center; color: var(--pn-fg-muted); flex: none; } +.rh-tile .ic { width: 28px; height: 28px; } +.recmini { display: flex; gap: 12px; align-items: center; padding: 12px; border: 1px solid var(--pn-line); border-radius: 10px; } +.cf { display: inline-flex; align-items: center; gap: 6px; max-width: 100%; vertical-align: middle; } +.rows { display: flex; flex-direction: column; } +.row { display: flex; align-items: center; gap: 12px; padding: 10px 16px; border-top: 1px solid var(--pn-line); min-height: 52px; } +.row .grow { flex: 1; min-width: 0; } +.row .meta { font-size: 12px; color: var(--pn-fg-muted); line-height: 16px; } +.row.top { align-items: flex-start; } +.grid2 { display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(0, 1fr); gap: 16px; align-items: start; } +.stack { display: flex; flex-direction: column; gap: 16px; min-width: 0; } +.cards3 { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 12px; } +@container app (max-width: 1099px) { .grid2 { grid-template-columns: minmax(0, 1fr); } } +.navcard { text-align: left; background: var(--pn-surface); border: 1px solid var(--pn-line); border-radius: 14px; padding: 16px; display: flex; flex-direction: column; gap: 12px; transition: background-color 120ms, border-color 120ms; width: 100%; } +.navcard:hover { background: var(--pn-hover); border-color: var(--pn-hover-line); } +.navcard .t { font-size: 15px; font-weight: 600; display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.navcard .lead { display: flex; align-items: center; gap: 8px; font-size: 13px; } +.navcard .stats { display: flex; gap: 16px; font-size: 13px; color: var(--pn-fg-muted); flex-wrap: wrap; } +.navcard .stats b { font-weight: 600; color: var(--pn-fg); font-variant-numeric: tabular-nums; } +.navcard .warn { color: var(--pn-warning-fg); display: inline-flex; gap: 4px; align-items: center; } +.sec-h { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 8px; } +.sec-h h2 { font-size: 17px; line-height: 24px; font-weight: 600; } + +/* Record header */ +.rec { display: flex; align-items: center; gap: 16px; } +.rec h1 { font-size: 28px; line-height: 32px; font-weight: 600; letter-spacing: -0.02em; } +.rec .l1 { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } +.rec .meta { color: var(--pn-fg-muted); margin-top: 4px; display: flex; gap: 6px; flex-wrap: wrap; align-items: center; } +.rec .roles { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; } +@container app (max-width: 759px) { .rec { align-items: flex-start; } .rec h1 { font-size: 24px; line-height: 30px; } .rec .av.s64 { width: 48px; height: 48px; font-size: 16px; } } + +/* Alerts */ +.alert { display: flex; gap: 10px; align-items: flex-start; padding: 12px 14px; border-radius: 10px; font-size: 14px; } +.alert .ic { width: 16px; height: 16px; margin-top: 2px; } +.alert .grow { flex: 1; min-width: 0; } +.alert .at { font-weight: 500; } +.alert.warning { background: var(--pn-warning-bg); color: var(--pn-warning-fg); } +.alert.danger { background: var(--pn-danger-bg); color: var(--pn-danger-fg); } +.alert.info { background: var(--pn-info-bg); color: var(--pn-info-fg); } +.alert.neutral { background: var(--pn-muted); color: var(--pn-fg-muted); } +.alert .btn { height: 32px; padding: 0 10px; margin: -6px -6px -6px 0; color: inherit; } +.alert .btn:hover { background: color-mix(in oklab, currentColor 10%, transparent); color: inherit; } + +/* Empty and skeleton */ +.empty { display: flex; flex-direction: column; align-items: center; text-align: center; gap: 8px; padding: 48px 24px; } +.empty .tile { width: 40px; height: 40px; border-radius: 8px; background: var(--pn-muted); color: var(--pn-fg-muted); display: grid; place-items: center; margin-bottom: 4px; } +.empty h2, .empty h3 { font-size: 18px; line-height: 24px; font-weight: 600; letter-spacing: -0.01em; } +.empty p { font-size: 13px; color: var(--pn-fg-muted); max-width: 40ch; } +.empty .acts { display: flex; gap: 8px; margin-top: 8px; } +.sk { background: var(--pn-muted); border-radius: 6px; animation: pulse 1.4s ease-in-out infinite; } +.sk.r { border-radius: 999px; } +@keyframes pulse { 50% { opacity: 0.55; } } +.skrow { display: flex; align-items: center; gap: 12px; height: 52px; padding: 0 16px; border-top: 1px solid var(--pn-line); } + +/* Forms */ +.form { display: flex; flex-direction: column; gap: 16px; } +.frow { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; } +.frow3 { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; } +@container app (max-width: 759px) { .frow, .frow3 { grid-template-columns: minmax(0, 1fr); } } +.field { display: flex; flex-direction: column; gap: 6px; min-width: 0; } +.lbl { font-weight: 500; font-size: 14px; } +.lbl .opt { font-weight: 400; color: var(--pn-fg-muted); } +.inp { width: 100%; height: 40px; border: 1px solid var(--pn-line-strong); border-radius: 8px; background: var(--pn-surface); padding: 0 12px; outline: none; } +.inp.mono { font-family: var(--mono); } +textarea.inp { height: 84px; padding: 10px 12px; resize: vertical; line-height: 20px; } +select.inp { padding-right: 8px; } +.inp:focus { border-color: var(--pn-focus); box-shadow: 0 0 0 3px color-mix(in oklab, var(--pn-focus) 25%, transparent); } +.inp.bad { border-color: var(--pn-danger-solid); } +.hint { font-size: 12px; line-height: 16px; color: var(--pn-fg-muted); } +.err { font-size: 12px; line-height: 16px; color: var(--pn-danger-fg); display: flex; gap: 4px; align-items: center; } +.err .ic { width: 14px; height: 14px; } +.opts { display: flex; flex-direction: column; gap: 8px; } +.optc { display: flex; align-items: flex-start; gap: 12px; padding: 12px; border: 1px solid var(--pn-line-strong); border-radius: 10px; background: var(--pn-surface); text-align: left; width: 100%; transition: background-color 120ms, border-color 120ms; } +.optc:hover { background: var(--pn-hover); border-color: var(--pn-hover-line); } +.optc.on { border-color: var(--pn-accent); background: var(--pn-accent-soft); } +.optc .radio { width: 18px; height: 18px; border-radius: 50%; border: 1.5px solid var(--pn-line-strong); flex: none; margin-top: 1px; display: grid; place-items: center; background: var(--pn-surface); } +.optc.on .radio { border-color: var(--pn-accent); } +.optc.on .radio::after { content: ""; width: 8px; height: 8px; border-radius: 50%; background: var(--pn-accent); } +.optc .box { width: 18px; height: 18px; border-radius: 5px; border: 1.5px solid var(--pn-line-strong); flex: none; margin-top: 1px; display: grid; place-items: center; background: var(--pn-surface); color: transparent; } +.optc.on .box { border-color: var(--pn-accent-solid); background: var(--pn-accent-solid); color: var(--pn-accent-solid-fg); } +.optc .box .ic { width: 12px; height: 12px; stroke-width: 3; } +.optc .grow { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; } +.optc .meta { font-size: 12px; color: var(--pn-fg-muted); line-height: 16px; } +.toggles { display: flex; gap: 6px; flex-wrap: wrap; } +.tgl { height: 32px; padding: 0 12px; border-radius: 999px; border: 1px solid var(--pn-line-strong); background: var(--pn-surface); display: inline-flex; align-items: center; gap: 6px; font-size: 13px; } +.tgl:hover { background: var(--pn-hover); } +.tgl.on { background: var(--pn-accent-soft); border-color: transparent; color: var(--pn-accent); } +.tgl .ic { width: 14px; height: 14px; } + +/* Dialogs */ +.scrim { position: absolute; inset: 0; z-index: 50; background: var(--pn-scrim); display: flex; align-items: center; justify-content: center; padding: 32px 16px; animation: fade 200ms var(--pn-ease-out); } +.dlg { width: 560px; max-width: 100%; max-height: 100%; display: flex; flex-direction: column; background: var(--pn-surface-raised); border-radius: 14px; box-shadow: var(--pn-shadow-modal); animation: dlgIn 200ms var(--pn-ease-out); } +.dlg.confirm { width: 460px; } +.dlg.lg { width: 920px; } +.dlg-h { padding: 20px 24px 0; display: flex; gap: 14px; align-items: flex-start; } +.dlg-h .tile { width: 40px; height: 40px; border-radius: 10px; flex: none; display: grid; place-items: center; background: var(--pn-warning-bg); color: var(--pn-warning-fg); } +.dlg-h .tile.d { background: var(--pn-danger-bg); color: var(--pn-danger-fg); } +.dlg-h .tile.n { background: var(--pn-muted); color: var(--pn-fg-muted); } +.dlg-h h2 { font-size: 18px; line-height: 24px; font-weight: 600; letter-spacing: -0.01em; } +.dlg-h p { color: var(--pn-fg-muted); margin-top: 4px; } +.dlg-h .grow { flex: 1; min-width: 0; } +.dlg-b { padding: 20px 24px; overflow-y: auto; min-height: 0; display: flex; flex-direction: column; gap: 16px; } +.dlg-f { padding: 16px 24px 20px; display: flex; justify-content: flex-end; align-items: center; gap: 8px; border-top: 1px solid var(--pn-line); } +.dlg-f .grow { flex: 1; } +.dlg-f.plain { border-top: 0; padding-top: 4px; } +@keyframes fade { from { opacity: 0; } to { opacity: 1; } } +@keyframes dlgIn { from { opacity: 0; transform: scale(0.97); } to { opacity: 1; transform: none; } } +@keyframes sheetIn { from { transform: translateY(24px); opacity: 0; } to { transform: none; opacity: 1; } } +@container app (max-width: 759px) { + .scrim { align-items: flex-end; padding: 24px 0 0; } + .dlg, .dlg.confirm, .dlg.lg { width: 100%; max-height: 92%; border-radius: 14px 14px 0 0; animation: sheetIn 200ms var(--pn-ease-out); } + .dlg-h { padding: 20px 16px 0; } + .dlg-b { padding: 16px; } + .dlg-f { padding: 12px 16px 16px; } + .dlg-f .btn { flex: 1; } + .dlg-f .grow { display: none; } +} +.steps { display: flex; gap: 4px; padding: 16px 24px 0; flex-wrap: wrap; } +.step { display: flex; align-items: center; gap: 8px; height: 32px; padding: 0 10px 0 4px; border-radius: 999px; color: var(--pn-fg-muted); font-size: 13px; } +.step .no { width: 24px; height: 24px; border-radius: 50%; display: grid; place-items: center; border: 1px solid var(--pn-line-strong); font: 500 12px var(--mono); } +.step.on { color: var(--pn-fg); font-weight: 500; } +.step.on .no { border-color: var(--pn-accent-solid); background: var(--pn-accent-solid); color: var(--pn-accent-solid-fg); } +.step.done .no { border-color: transparent; background: var(--pn-accent-soft); color: var(--pn-accent); } +.step .ic { width: 12px; height: 12px; stroke-width: 2.5; } +.step-sep { width: 16px; height: 1px; background: var(--pn-line-strong); align-self: center; } +@container app (max-width: 759px) { .steps { padding: 12px 16px 0; } .step .lab { display: none; } .step.on .lab { display: inline; } } +.drop { border: 1.5px dashed var(--pn-line-strong); border-radius: 12px; padding: 32px 16px; display: flex; flex-direction: column; align-items: center; gap: 8px; text-align: center; background: var(--pn-sunken); } +.filecard { display: flex; align-items: center; gap: 12px; padding: 12px; border: 1px solid var(--pn-line-strong); border-radius: 10px; background: var(--pn-surface); text-align: left; width: 100%; } +.filecard:hover { background: var(--pn-hover); border-color: var(--pn-hover-line); } +.filecard.on { border-color: var(--pn-accent); background: var(--pn-accent-soft); } +.filecard .fi { width: 36px; height: 36px; border-radius: 8px; display: grid; place-items: center; background: var(--pn-success-bg); color: var(--pn-success-fg); flex: none; } +.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 8px; } +.stat { border: 1px solid var(--pn-line); border-radius: 10px; padding: 10px 12px; background: var(--pn-surface); } +.stat .v { font-size: 20px; line-height: 28px; font-weight: 600; font-variant-numeric: tabular-nums; } +.stat .l { font-size: 12px; color: var(--pn-fg-muted); } +.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--pn-line); overflow-x: auto; scrollbar-width: none; } +.tab { position: relative; height: 40px; padding: 0 10px; display: inline-flex; align-items: center; gap: 6px; color: var(--pn-fg-muted); white-space: nowrap; } +.tab:hover { color: var(--pn-fg); } +.tab.on { color: var(--pn-fg); } +.tab.on::after { content: ""; position: absolute; left: 6px; right: 6px; bottom: -1px; height: 2px; border-radius: 2px; background: var(--pn-accent); } +.tab .n { font: 500 12px var(--mono); color: var(--pn-fg-muted); } +.cmp { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); border: 1px solid var(--pn-line); border-radius: 12px; overflow: hidden; } +.cmp .side { padding: 14px 16px; display: flex; flex-direction: column; gap: 10px; min-width: 0; } +.cmp .side + .side { border-left: 1px solid var(--pn-line); } +.cmp .side h3 { font-size: 13px; font-weight: 500; color: var(--pn-fg-muted); } +.cmp .kv { grid-template-columns: 110px minmax(0, 1fr); gap: 6px 12px; } +.cmp mark { background: var(--pn-warning-bg); color: var(--pn-warning-fg); border-radius: 4px; padding: 0 4px; } +@container app (max-width: 759px) { .cmp { grid-template-columns: minmax(0, 1fr); } .cmp .side + .side { border-left: 0; border-top: 1px solid var(--pn-line); } .cmp .kv { grid-template-columns: minmax(0, 1fr); } } + +/* Progress */ +.bar { display: flex; height: 8px; border-radius: 999px; overflow: hidden; background: var(--pn-muted); gap: 2px; } +.bar span { display: block; height: 100%; } +.bar .b-ok { background: var(--pn-success-solid); } +.bar .b-leave { background: var(--pn-warning-solid); } +.bar .b-acc { background: var(--pn-accent-solid); } +.legend { display: flex; gap: 16px; flex-wrap: wrap; font-size: 13px; color: var(--pn-fg-muted); } +.legend span { display: inline-flex; align-items: center; gap: 6px; } +.legend i { width: 8px; height: 8px; border-radius: 2px; display: inline-block; } +.legend b { color: var(--pn-fg); font-weight: 600; } +.meter { display: flex; align-items: center; gap: 8px; } +.meter .bar { flex: 1; height: 6px; min-width: 60px; } + +/* Toasts */ +.toasts { position: absolute; right: 24px; bottom: 24px; z-index: 70; display: flex; flex-direction: column; gap: 8px; align-items: flex-end; pointer-events: none; } +.toast { display: flex; align-items: center; gap: 10px; min-width: 260px; max-width: 380px; padding: 12px 14px; border-radius: 12px; background: var(--pn-surface-raised); color: var(--pn-fg); box-shadow: var(--pn-shadow-float); animation: toastIn 200ms var(--pn-ease-out); pointer-events: auto; } +.toast .ic { width: 18px; height: 18px; } +.toast.success .ic { color: var(--pn-success-fg); } +.toast.error .ic { color: var(--pn-danger-fg); } +.toast.info .ic { color: var(--pn-accent); } +@keyframes toastIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } } +@container app (max-width: 759px) { .toasts { left: 16px; right: 16px; bottom: 76px; align-items: stretch; } .toast { max-width: none; } } + +/* My area */ +.hero { display: flex; gap: 16px; align-items: center; } +.match { max-width: 560px; margin: 24px auto 0; width: 100%; } +.census-box { border: 1px solid var(--pn-accent); border-radius: 12px; background: var(--pn-surface); } +.inline-link { color: var(--pn-accent); font-weight: 500; } +.inline-link:hover { color: var(--pn-accent-hover); text-decoration: underline; } +.split { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; } +.gap8 { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } +.gap4 { display: flex; gap: 4px; align-items: center; flex-wrap: wrap; } +.col8 { display: flex; flex-direction: column; gap: 8px; } +.qa { display: grid; grid-template-columns: minmax(0, 1fr); gap: 2px; padding: 10px 0; border-top: 1px solid var(--pn-line); } +.qa:first-child { border-top: 0; padding-top: 0; } +.qa .q { font-size: 12px; color: var(--pn-fg-muted); } +.stub { padding: 64px 24px; } diff --git a/docs/people/prototype/prototype.dc.html b/docs/people/prototype/prototype.dc.html new file mode 100644 index 00000000..889a3658 --- /dev/null +++ b/docs/people/prototype/prototype.dc.html @@ -0,0 +1,1899 @@ + + + + +People prototype + + + + + + + + + +
+ + + +
+Prototype + + + + + +
+
+ +
+
+
+ + + + + + + +
+
+ +{{topTitle}} + + + +
+ +
+ + + +
+ + +
+
+ +/ +{{ph.current}} +
+
+ + + + +
+
+
+ + +
+
+ +

{{ph.title}}

{{ph.ctx}}

+
+
+ + +
+
+
+ + +{{SK.label}} + +
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+ + + + +
+ + +
+

{{RH.title}}

{{RH.badge.l}}
+

{{m.t}}

+
{{c}}
+
+
+
+ + +
{{a.text}}
+
+ + +
+ + + +
+
+ + +
+
{{CP.headline}}{{CP.pct}}
+ +
ContinuingLeavingNo answer{{CP.period}}
+
+
+ + + +
+

{{MC.title}}

{{MC.closes}}
+
+

Tell us whether you're staying on. We filled in last year's answers: check them and confirm.

+
+
+
{{MC.yearHint}}
+
+
+
Groups you'll keep administering
Groups you deselect are handed to your Capo Admin.
+
Teams you're continuing with
+
+
+
+
+ +
{{MC.done.title}}{{MC.done.badge}}
{{MC.done.meta}}
+
+
+ + +
+
+ +

Is this you?

+
+

We found a record that matches your sign-in. Linking it shows your roles here and lets you answer the census.

+
{{MATCH.name}}
{{MATCH.id}} · {{MATCH.meta}}
{{c}}
+
{{MATCH.why}}
+
+
+
+ +

HR will check your sign-in

We told HR the matched record isn't you. You'll get an email when your account is linked to the right record.

Waiting for HR
+
+
+
+
+ + +
+

You're not on the volunteer or member lists

If you administer groups, belong to a team or are a member, ask to be added. HR matches your sign-in to your record.

+

Request sent

HR will add you or link you to your record. Sent {{NOREC.on}}.

Waiting for HR
+
+
+ + +
+ +
+ +
+
+

{{cd.title}}{{cd.badge.l}}{{cd.count}}

+
+ + + +
+
+
{{cd.alert.text}}
+ +
+ +
{{r.k}} + +{{p.t}} +{{r.bt}} +{{ch.t}} + +
+
+
+
+

{{cd.text}}

+ +
+ +
{{r.h}}
+ +
+ + + +
+
{{p.t}}{{r.chip}}
+
{{r.meta}}
+
{{p.t}}
+
+{{r.badge.l}} + + + + +
+
+
+
+
+

{{cd.empty}}

+
+
+
+
+
+
+
+ +

{{SECH.title}}

{{SECH.ctx}}
+ + +
+
+ + + + + + + + +{{tb.count}} + +
+
+ + +
+ + + + + + + + + + + + + + +
{{h.label}}
+{{c.lab}}: +
{{c.name}}{{c.sub}}
+ +{{p.t}} +{{c.bt}} +{{c.lead}}{{ch.t}}{{pp}} +{{c.meter.t}} + + + + + + +
+ +
+{{T.foot.text}} +
+
+ +
{{T.foot.mtext}}
+
+
+
+
+ +

{{T.empty.title}}

{{T.empty.text}}

+
+
+
+ + +

Outside this prototype

{{STUB.text}}

+
+ +
+
+
+ + + + +
+
+ + +
+ + +
+

{{dl.title}}

{{dl.desc}}

+
+
+
+ + + + + + + + + + +
+

Change member number?

{{dl.desc}}

+
+
{{dl.err.num}}{{dl.hint}}
+
{{dl.m365}}
+
+
+
+
+ + + + + + +
+

End membership?

{{dl.desc}}

+
+
{{dl.err.reason}}
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + +
+
+ +
+
{{t.text}}
+
+ +
+
+
+ +
+ + + diff --git a/vite.config.ts b/vite.config.ts index 53451475..25a0ff8e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,14 +6,14 @@ import { defineConfig, lazyPlugins } from "vite-plus" export default defineConfig({ fmt: { - ignorePatterns: [".output/**", ".tanstack/**", "src/routeTree.gen.ts"], + ignorePatterns: [".output/**", ".tanstack/**", "src/routeTree.gen.ts", "docs/people/prototype/**"], printWidth: 120, semi: false, sortImports: true, trailingComma: "es5", }, lint: { - ignorePatterns: [".output/**", ".tanstack/**", "src/routeTree.gen.ts"], + ignorePatterns: [".output/**", ".tanstack/**", "src/routeTree.gen.ts", "docs/people/prototype/**"], jsPlugins: [ { name: "anti-slop", specifier: "@zap-studio/oxlint/anti-slop" }, { name: "vite-plus", specifier: "vite-plus/oxlint-plugin" }, From 48bd91f0088fd5bd017cd84a330c95c3e70efaa8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 12:17:54 +0000 Subject: [PATCH 02/24] feat(people): add People permissions, restricted access and sample data - New permissions: people:self, people:read, people:write, people:members:read, people:members:write, people:appoint. - people:self alone admits a user to the dashboard; adminMiddleware now requires admin:access explicitly so existing reads stay closed to them. - People middlewares for each scope, and dev-only AGENT_PERMISSIONS and AGENT_PERSON to preview restricted roles in agent mode. - Typed people.* contract (zod) and the agent-mode sample data ported from the prototype. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ti91VKqKpduLAF376TunMn --- src/env.ts | 4 + src/features/people/people.contract.ts | 378 ++++++ src/features/people/people.fixtures.server.ts | 1176 +++++++++++++++++ src/lib/auth.ts | 4 +- src/server/auth.middleware.ts | 74 +- src/server/auth.server.ts | 10 +- src/server/permissions.ts | 24 + 7 files changed, 1664 insertions(+), 6 deletions(-) create mode 100644 src/features/people/people.contract.ts create mode 100644 src/features/people/people.fixtures.server.ts diff --git a/src/env.ts b/src/env.ts index 3dc6dccd..1040ed07 100644 --- a/src/env.ts +++ b/src/env.ts @@ -9,6 +9,10 @@ export const env = createEnv({ .enum(["true", "false"]) .default("false") .transform((value) => value === "true"), + /** Development only: comma-separated permissions for the agent-mode user (default: all of them). */ + AGENT_PERMISSIONS: z.string().optional(), + /** Development only: the agent-mode user's person record in the People sample data. */ + AGENT_PERSON: z.string().default("P-0007"), NODE_ENV: z.enum(["development", "test", "production"]), BACKEND_URL: z.url(), }, diff --git a/src/features/people/people.contract.ts b/src/features/people/people.contract.ts new file mode 100644 index 00000000..6a10b650 --- /dev/null +++ b/src/features/people/people.contract.ts @@ -0,0 +1,378 @@ +import { z } from "zod" + +/** + * The `people.*` backend contract (docs/people/contract.md). The backend doesn't serve it yet: agent mode answers from + * `people.fake.server.ts`, and real responses are parsed with these schemas before they reach a page. + */ + +export const TEAM_IDS = ["it", "hr", "design", "intl", "events"] as const +export const CAMPUSES = ["Leonardo", "Bovisa", "Cremona", "Piacenza", "Mantova", "Lecco", "External"] as const +export const YEARS = ["1st year", "2nd year", "3rd year", "1st year MSc", "2nd year MSc", "Graduated"] as const +export const MEMBER_STATUSES = ["active", "suspended", "expired", "former"] as const +export const FEE_STATUSES = ["due", "receipt", "paid", "unpaid"] as const +export const ACCOUNT_STATUSES = ["none", "invite", "linked", "review"] as const +export const CENSUS_ANSWERS = ["continues", "leaving"] as const +export const REVIEW_KINDS = ["telegram-conflict", "ask-to-be-added", "two-records", "not-me", "name-only"] as const + +export const teamIdSchema = z.enum(TEAM_IDS) +export const campusSchema = z.enum(CAMPUSES) +export const memberStatusSchema = z.enum(MEMBER_STATUSES) +export const feeStatusSchema = z.enum(FEE_STATUSES) +export const censusAnswerSchema = z.enum(CENSUS_ANSWERS) + +export type TeamId = z.infer +export type Campus = z.infer +export type MemberStatus = z.infer +export type FeeStatus = z.infer +export type CensusAnswer = z.infer +export type AccountStatus = (typeof ACCOUNT_STATUSES)[number] +export type ReviewKind = (typeof REVIEW_KINDS)[number] + +const personRefSchema = z.object({ id: z.string(), name: z.string() }) +const groupSchema = z.object({ chatId: z.string(), title: z.string() }) + +export const profileSchema = z.object({ + firstName: z.string(), + lastName: z.string(), + /** `null` without contact rights. */ + personalEmail: z.string().nullable(), + /** `null` without contact rights. */ + phone: z.string().nullable(), + telegramUsername: z.string().nullable(), + telegramId: z.string().nullable(), + course: z.string(), + year: z.string(), + campus: campusSchema, + otherAssociations: z.array(z.string()), + studentRepresentative: z.string().nullable(), +}) + +export const membershipSchema = z.object({ + number: z.string(), + status: memberStatusSchema, + admittedOn: z.string(), + /** The current academic year's fee; `null` for former members. */ + fee: feeStatusSchema.nullable(), + m365Email: z.string(), +}) + +export const adminRoleSchema = z.object({ + status: z.enum(["active", "former"]), + since: z.string(), + endedOn: z.string().nullable(), + endReason: z.string().nullable(), + /** Detected by the bot. */ + groups: z.array(groupSchema), + requested: z.array(groupSchema.extend({ requestedOn: z.string() })), + availableAsCapo: z.boolean(), + capoOfReference: personRefSchema.nullable(), + /** Set when this person is a Capo Admin, e.g. "Ingegneria Informatica". */ + capoScope: z.string().nullable(), +}) + +export const teamFieldsSchema = z.record(z.string(), z.union([z.string(), z.array(z.string())])) + +export const teamMembershipSchema = z.object({ + team: teamIdSchema, + role: z.enum(["member", "lead"]), + since: z.string(), + endedOn: z.string().nullable(), + endReason: z.string().nullable(), + fields: teamFieldsSchema, +}) + +export const censusEntrySchema = z.object({ + year: z.string(), + /** `null`: expected, no answer yet. */ + answer: censusAnswerSchema.nullable(), + answeredOn: z.string().nullable(), + source: z.enum(["forms", "dashboard"]).nullable(), + declaredMember: z.boolean().nullable(), + reminders: z.number().int(), +}) + +export const accountSchema = z.discriminatedUnion("status", [ + z.object({ status: z.literal("none") }), + z.object({ + status: z.literal("invite"), + email: z.string(), + sentOn: z.string(), + expiresOn: z.string(), + sentBy: z.string(), + }), + z.object({ status: z.literal("linked"), email: z.string(), via: z.string(), linkedOn: z.string(), how: z.string() }), + z.object({ status: z.literal("review"), reviewId: z.string(), issue: z.string() }), +]) + +export const consentsSchema = z.object({ + privacy: z.object({ version: z.string(), acceptedOn: z.string() }).nullable(), + photo: z.object({ status: z.enum(["granted", "denied", "revoked"]), on: z.string() }).nullable(), +}) + +/** One row of the People lists: everything but notes, activity and discrepancies. */ +export const personRowSchema = z.object({ + id: z.string(), + profile: profileSchema, + member: membershipSchema.nullable(), + admin: adminRoleSchema.nullable(), + teams: z.array(teamMembershipSchema), + census: z.array(censusEntrySchema), + consents: consentsSchema, + account: accountSchema, +}) + +export const discrepancySchema = z.object({ + kind: z.enum(["declared-member", "telegram-mismatch", "needs-review"]), + message: z.string(), + suggestedTelegram: z.string().nullable(), +}) + +export const activitySchema = z.object({ actor: z.string(), text: z.string(), at: z.string() }) + +export const personRecordSchema = personRowSchema.extend({ + /** `null` without `people:read`. */ + notes: z.string().nullable(), + discrepancies: z.array(discrepancySchema), + activity: z.array(activitySchema), +}) + +export type Profile = z.infer +export type Membership = z.infer +export type AdminRole = z.infer +export type TeamFields = z.infer +export type TeamMembership = z.infer +export type CensusEntry = z.infer +export type Account = z.infer +export type Consents = z.infer +export type PersonRow = z.infer +export type PersonRecord = z.infer +export type Discrepancy = z.infer + +export const teamSummarySchema = z.object({ + id: teamIdSchema, + name: z.string(), + lead: personRefSchema.nullable(), + members: z.array(personRowSchema), +}) +export type TeamSummary = z.infer + +export const campaignSchema = z.object({ + id: z.string(), + year: z.string(), + status: z.enum(["open", "closed"]), + openedOn: z.string(), + closesOn: z.string(), + source: z.string(), + expected: z.number().int(), + continuing: z.number().int(), + leaving: z.number().int(), + noAnswer: z.number().int(), +}) +export type Campaign = z.infer + +export const campaignDetailSchema = z.object({ campaign: campaignSchema, people: z.array(personRowSchema) }) +export type CampaignDetail = z.infer + +export const censusAnswersSchema = z.array(z.object({ question: z.string(), answer: z.string() })) + +export const reviewSchema = z.object({ + id: z.string(), + kind: z.enum(REVIEW_KINDS), + since: z.string(), + account: z.object({ name: z.string(), email: z.string(), telegram: z.string().nullable(), via: z.string() }), + record: personRefSchema.nullable(), + other: personRefSchema.nullable(), + message: z.string().nullable(), +}) +export type Review = z.infer + +export const inviteSchema = z.object({ + personId: z.string(), + name: z.string(), + email: z.string(), + sentOn: z.string(), + expiresOn: z.string(), + sentBy: z.string(), +}) +export type Invite = z.infer + +export const accountMatchSchema = z.object({ + id: z.string(), + name: z.string(), + email: z.string(), + telegram: z.string().nullable(), + via: z.string(), + linkedTo: personRefSchema.nullable(), + reason: z.enum(["same-telegram", "same-email", "similar-name"]), +}) +export type AccountMatch = z.infer + +const importRowSchema = z.object({ + row: z.number().int(), + name: z.string(), + detail: z.string(), + answer: censusAnswerSchema.nullable(), +}) +export const IMPORT_TABS = [ + "newPeople", + "updates", + "duplicates", + "invalid", + "discrepancies", + "alreadyImported", +] as const +export type ImportTab = (typeof IMPORT_TABS)[number] + +export const importPreviewSchema = z.object({ + importId: z.string(), + fileName: z.string(), + rows: z.number().int(), + counts: z.object({ + newPeople: z.number().int(), + updates: z.number().int(), + duplicates: z.number().int(), + invalid: z.number().int(), + discrepancies: z.number().int(), + alreadyImported: z.number().int(), + leaving: z.number().int(), + }), + tabs: z.object({ + newPeople: z.array(importRowSchema), + updates: z.array(importRowSchema), + duplicates: z.array(importRowSchema), + invalid: z.array(importRowSchema), + discrepancies: z.array(importRowSchema), + alreadyImported: z.array(importRowSchema), + }), + duplicates: z.array( + z.object({ + pairId: z.string(), + row: z.number().int(), + recordId: z.string(), + fields: z.array(z.object({ label: z.string(), file: z.string(), record: z.string(), mono: z.boolean() })), + }) + ), + telegram: z.array( + z.object({ + rowId: z.string(), + name: z.string(), + username: z.string(), + suggestion: z.object({ telegramId: z.string(), name: z.string(), info: z.string() }).nullable(), + }) + ), +}) +export type ImportPreview = z.infer + +export const capoAreaSchema = z.object({ + scope: z.string(), + groups: z.array(groupSchema.extend({ admins: z.number().int() })), + admins: z.array(personRowSchema), + requests: z.array(z.object({ id: z.string(), person: personRefSchema, group: groupSchema, requestedOn: z.string() })), +}) +export type CapoArea = z.infer + +export const myRolesSchema = z.object({ + /** The Capo Admin scope, when the signed-in person is one. */ + capoScope: z.string().nullable(), + /** Teams the signed-in person leads. */ + leads: z.array(teamIdSchema), +}) +export type MyRoles = z.infer + +const contactSchema = z.object({ + role: z.enum(["capo", "lead", "hr"]), + name: z.string(), + telegram: z.string().nullable(), + email: z.string().nullable(), + team: teamIdSchema.nullable(), +}) + +export const myAreaSchema = z.discriminatedUnion("state", [ + z.object({ + state: z.literal("linked"), + person: personRecordSchema, + campaign: z.object({ id: z.string(), year: z.string(), closesOn: z.string() }).nullable(), + contacts: z.array(contactSchema), + roster: z + .object({ + team: teamIdSchema, + members: z.array( + z.object({ + id: z.string(), + name: z.string(), + role: z.enum(["member", "lead"]), + telegram: z.string().nullable(), + email: z.string().nullable(), + }) + ), + }) + .nullable(), + }), + z.object({ + state: z.literal("match"), + candidate: z.object({ + id: z.string(), + name: z.string(), + course: z.string(), + year: z.string(), + campus: campusSchema, + roles: z.array(z.string()), + }), + reason: z.string(), + }), + z.object({ state: z.literal("none"), request: z.object({ sentOn: z.string() }).nullable() }), +]) +export type MyArea = z.infer + +/** The team-specific fields, in display order. */ +export type TeamField = { key: string; label: string; kind: "text" | "multi"; mono?: boolean; options?: string[] } + +export const TEAMS = { + it: { + name: "IT", + fields: [ + { key: "github", label: "GitHub", kind: "text", mono: true }, + { key: "areas", label: "Areas", kind: "multi", options: ["Frontend", "Backend", "Bot", "Infrastructure"] }, + ], + }, + hr: { + name: "HR", + fields: [ + { key: "focus", label: "Focus", kind: "multi", options: ["Recruiting", "Onboarding", "Census", "Wellbeing"] }, + ], + }, + design: { + name: "Design & Social", + fields: [ + { + key: "tools", + label: "Tools", + kind: "multi", + options: ["Figma", "Illustrator", "Photoshop", "After Effects", "InDesign", "Premiere"], + }, + { key: "portfolio", label: "Portfolio", kind: "text" }, + ], + }, + intl: { + name: "International", + fields: [ + { + key: "languages", + label: "Languages", + kind: "multi", + options: ["English", "Spanish", "French", "German", "Chinese", "Portuguese"], + }, + ], + }, + events: { + name: "Events & Partnerships", + fields: [ + { + key: "focus", + label: "Focus", + kind: "multi", + options: ["Partnerships", "Logistics", "Sponsorships", "Communication"], + }, + ], + }, +} satisfies Record diff --git a/src/features/people/people.fixtures.server.ts b/src/features/people/people.fixtures.server.ts new file mode 100644 index 00000000..50c7fddc --- /dev/null +++ b/src/features/people/people.fixtures.server.ts @@ -0,0 +1,1176 @@ +import { + type Account, + type AdminRole, + CAMPUSES, + type CensusEntry, + type FeeStatus, + type PersonRecord, + type Review, + type TeamFields, + type TeamId, + type TeamMembership, +} from "./people.contract" + +/** + * Agent-mode sample data for the People area, ported from the prototype's `buildData()` (docs/people/prototype): + * 148 people, ~40 linked accounts, ~60 members, ~110 admins, 27 team members, with the same seed and key people. + */ + +export const SAMPLE_TODAY = "2026-10-14" + +export type FamilyId = "inf" | "ges" | "ind" | "ele" | "des" | "arc" + +/** A record as the fake backend stores it: the contract's record plus what it derives views from. */ +export type StoredPerson = Omit & { + family: FamilyId + /** The first day of any role, for the census history. */ + start: string | null + /** The Telegram username given in the 2026/27 census, when it differs from the record. */ + censusTelegram: string | null + /** Whether the person is expected to answer the 2026/27 census. */ + expected: boolean +} + +export type JoinRequest = { id: string; personId: string; group: string; requestedOn: string } + +export type PeopleStore = { + people: StoredPerson[] + byId: Map + reviews: Review[] + joinRequests: JoinRequest[] +} + +const FIRST = [ + "Giulia", + "Davide", + "Sara", + "Matteo", + "Elena", + "Marta", + "Luca", + "Marco", + "Chiara", + "Federico", + "Alessia", + "Tommaso", + "Francesca", + "Lorenzo", + "Sofia", + "Andrea", + "Martina", + "Alessandro", + "Giorgia", + "Riccardo", + "Beatrice", + "Simone", + "Valentina", + "Gabriele", + "Aurora", + "Pietro", + "Camilla", + "Edoardo", + "Ludovica", + "Filippo", + "Noemi", + "Nicolò", + "Elisa", + "Giacomo", + "Anna", + "Stefano", + "Irene", + "Michele", + "Greta", + "Emanuele", + "Ilaria", + "Daniele", + "Arianna", + "Leonardo", + "Benedetta", + "Samuele", + "Rebecca", + "Christian", + "Alice", + "Mattia", + "Gaia", + "Jacopo", + "Laura", + "Alberto", + "Silvia", + "Paolo", + "Eleonora", + "Antonio", + "Caterina", + "Vittoria", + "Giovanni", + "Margherita", + "Enrico", + "Carlotta", + "Fabio", + "Bianca", + "Diego", + "Serena", + "Nicole", + "Manuel", + "Federica", + "Kevin", + "Asia", + "Yasmine", + "Omar", +] + +const LAST = [ + "Rossi", + "Russo", + "Ferrari", + "Esposito", + "Bianchi", + "Romano", + "Colombo", + "Ricci", + "Marino", + "Greco", + "Bruno", + "Gallo", + "Conti", + "De Luca", + "Mancini", + "Costa", + "Giordano", + "Rizzo", + "Lombardi", + "Moretti", + "Barbieri", + "Fontana", + "Santoro", + "Mariani", + "Rinaldi", + "Caruso", + "Ferri", + "Galli", + "Martini", + "Leone", + "Longo", + "Gentile", + "Martinelli", + "Vitale", + "Lombardo", + "Serra", + "Coppola", + "De Santis", + "D'Angelo", + "Marchetti", + "Parisi", + "Villa", + "Conte", + "Ferraro", + "Fabbri", + "Bianco", + "Marini", + "Grasso", + "Valentini", + "Messina", + "Sala", + "De Angelis", + "Gatti", + "Pellegrini", + "Palumbo", + "Sanna", + "Farina", + "Rizzi", + "Monti", + "Cattaneo", + "Morelli", + "Amato", + "Silvestri", + "Mazza", + "Testa", + "Grassi", + "Carbone", + "Giuliani", + "Benedetti", + "Barone", + "Caputo", + "Montanari", + "Guerra", + "Palmieri", + "Bernardi", + "Fiore", + "De Rosa", + "Ferretti", + "Bellini", + "Basile", + "Riva", + "Donati", + "Piras", + "Sartori", + "Neri", + "Milani", + "Pagano", + "Orlando", + "Negri", + "Brambilla", + "Crippa", + "Mauri", + "Pozzi", + "Galbiati", +] + +type Course = { name: string; family: FamilyId; campuses: string[]; degree: "b" | "m"; weight: number } + +const COURSES: Course[] = [ + { + name: "Ingegneria Informatica", + family: "inf", + campuses: ["Leonardo", "Leonardo", "Leonardo", "Cremona"], + degree: "b", + weight: 11, + }, + { name: "Computer Science and Engineering", family: "inf", campuses: ["Leonardo"], degree: "m", weight: 3 }, + { name: "Ingegneria Gestionale", family: "ges", campuses: ["Bovisa", "Bovisa", "Cremona"], degree: "b", weight: 7 }, + { name: "Management Engineering", family: "ges", campuses: ["Bovisa"], degree: "m", weight: 2 }, + { name: "Ingegneria Meccanica", family: "ind", campuses: ["Bovisa", "Piacenza", "Lecco"], degree: "b", weight: 5 }, + { name: "Ingegneria Aerospaziale", family: "ind", campuses: ["Bovisa"], degree: "b", weight: 4 }, + { name: "Ingegneria Energetica", family: "ind", campuses: ["Bovisa", "Piacenza"], degree: "b", weight: 2 }, + { name: "Ingegneria Chimica", family: "ind", campuses: ["Leonardo"], degree: "b", weight: 2 }, + { name: "Ingegneria Elettronica", family: "ele", campuses: ["Leonardo"], degree: "b", weight: 3 }, + { name: "Ingegneria Biomedica", family: "ele", campuses: ["Leonardo"], degree: "b", weight: 4 }, + { name: "Ingegneria Matematica", family: "ele", campuses: ["Leonardo"], degree: "b", weight: 2 }, + { name: "Design della Comunicazione", family: "des", campuses: ["Bovisa"], degree: "b", weight: 4 }, + { name: "Design del Prodotto Industriale", family: "des", campuses: ["Bovisa"], degree: "b", weight: 3 }, + { name: "Design degli Interni", family: "des", campuses: ["Bovisa"], degree: "b", weight: 2 }, + { name: "Architettura", family: "arc", campuses: ["Leonardo", "Mantova", "Piacenza"], degree: "b", weight: 4 }, + { name: "Ingegneria Civile", family: "arc", campuses: ["Leonardo", "Lecco"], degree: "b", weight: 2 }, +] + +export const COURSE_NAMES = COURSES.map((course) => course.name) + +export const FAMILIES = { + inf: { scope: "Ingegneria Informatica", groups: ["Fondamenti di Informatica", "Basi di Dati", "Reti Logiche"] }, + ges: { + scope: "Ingegneria Gestionale", + groups: ["Economia e Organizzazione Aziendale", "Ricerca Operativa", "Statistica", "Gestione dei Progetti"], + }, + ind: { + scope: "Ingegneria Industriale", + groups: ["Fisica Tecnica", "Meccanica Razionale", "Termodinamica Applicata", "Aerodinamica"], + }, + ele: { + scope: "Ingegneria dell'Informazione", + groups: ["Elettrotecnica", "Fondamenti di Elettronica", "Segnali e Sistemi", "Geometria e Algebra Lineare"], + }, + des: { + scope: "Design", + groups: ["Laboratorio di Comunicazione Visiva", "Storia del Design", "Disegno per il Design"], + }, + arc: { + scope: "Architettura", + groups: ["Storia dell'Architettura", "Laboratorio di Progettazione 1", "Scienza delle Costruzioni"], + }, +} satisfies Record + +const FAMILY_IDS: FamilyId[] = ["inf", "ges", "ind", "ele", "des", "arc"] + +/** Stable fake Telegram chat ids per group title. */ +export const GROUP_IDS: Map = new Map( + FAMILY_IDS.flatMap((family, familyIndex) => + FAMILIES[family].groups.map((title, index): [string, string] => [ + title, + `-100${1734200000 + familyIndex * 977331 + index * 40517}`, + ]) + ) +) + +const REPS = ["Consiglio di Corso di Studio", "Giunta di Scuola", "Consiglio degli Studenti", "Senato Accademico"] +const ASSOCIATIONS = [ + "Skyward", + "Dynamis PRC", + "ESN Politecnico Milano", + "BEST Milano", + "JEMP", + "PoliMi Sailing Team", + "Poli Radio", +] +const NOTES = [ + "Prefers to be contacted on Telegram.", + "Away on Erasmus from February to July.", + "Interested in joining IT next semester.", + "Asked to step down from one group at the end of the year.", + "Helped run the freshers' open day.", +] + +type KeyPerson = { + first: string + last: string + course: string + family: FamilyId + year: string + campus: string + tg: string +} + +const KEYS = { + "P-0142": { + first: "Giulia", + last: "Rossi", + course: "Ingegneria Informatica", + family: "inf", + year: "2nd year", + campus: "Leonardo", + tg: "giuliarossi", + }, + "P-0098": { + first: "Davide", + last: "Russo", + course: "Computer Science and Engineering", + family: "inf", + year: "1st year MSc", + campus: "Leonardo", + tg: "davide_russo", + }, + "P-0034": { + first: "Sara", + last: "Galli", + course: "Computer Science and Engineering", + family: "inf", + year: "2nd year MSc", + campus: "Leonardo", + tg: "saragalli", + }, + "P-0115": { + first: "Matteo", + last: "Conti", + course: "Ingegneria Informatica", + family: "inf", + year: "2nd year", + campus: "Leonardo", + tg: "matteoconti", + }, + "P-0063": { + first: "Elena", + last: "Ricci", + course: "Ingegneria Gestionale", + family: "ges", + year: "2nd year", + campus: "Bovisa", + tg: "elenaricci", + }, + "P-0007": { + first: "Marta", + last: "Colombo", + course: "Management Engineering", + family: "ges", + year: "2nd year MSc", + campus: "Bovisa", + tg: "martacolombo", + }, + "P-0021": { + first: "Luca", + last: "Ferri", + course: "Ingegneria Biomedica", + family: "ele", + year: "3rd year", + campus: "Leonardo", + tg: "lucaferri", + }, + "P-0045": { + first: "Alessia", + last: "Fontana", + course: "Design della Comunicazione", + family: "des", + year: "3rd year", + campus: "Bovisa", + tg: "alessiafontana", + }, + "P-0052": { + first: "Tommaso", + last: "Greco", + course: "Architettura", + family: "arc", + year: "2nd year", + campus: "Mantova", + tg: "tommygreco", + }, + "P-0088": { + first: "Marco", + last: "Bianchi", + course: "Ingegneria Meccanica", + family: "ind", + year: "2nd year", + campus: "Piacenza", + tg: "marcobianchi", + }, + "P-0131": { + first: "Chiara", + last: "Esposito", + course: "Design degli Interni", + family: "des", + year: "Graduated", + campus: "External", + tg: "chiaraesp", + }, + "P-0076": { + first: "Federico", + last: "Marino", + course: "Ingegneria Aerospaziale", + family: "ind", + year: "1st year", + campus: "Bovisa", + tg: "fedemarino", + }, +} satisfies Record + +/** Lowercase ASCII without spaces or apostrophes, for emails and usernames. */ +export function slug(text: string) { + return text + .normalize("NFD") + .replace(/[̀-ͯ]/g, "") + .replace(/['\s]/g, "") + .toLowerCase() +} + +export function m365Address(firstName: string, lastName: string) { + return `${slug(firstName)}.${slug(lastName)}@polinetwork.org` +} + +export function addDays(date: string, days: number) { + const next = new Date(`${date}T12:00:00Z`) + next.setUTCDate(next.getUTCDate() + days) + return next.toISOString().slice(0, 10) +} + +function pad(value: number, width: number) { + return String(value).padStart(width, "0") +} + +function mulberry32(seed: number) { + let state = seed + return () => { + state = (state + 0x6d2b79f5) | 0 + let t = Math.imul(state ^ (state >>> 15), 1 | state) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +function campusOf(value: string) { + const campus = CAMPUSES.find((candidate) => candidate === value) + if (!campus) throw new Error(`Unknown campus ${value}`) + return campus +} + +function group(title: string) { + return { chatId: GROUP_IDS.get(title) ?? "", title } +} + +export function buildPeopleStore(): PeopleStore { + const random = mulberry32(1442) + const pick = (items: readonly T[]) => items[Math.floor(random() * items.length)] + const weighted = (items: Course[]) => { + let remaining = random() * items.reduce((sum, item) => sum + item.weight, 0) + for (const item of items) { + remaining -= item.weight + if (remaining < 0) return item + } + return items[items.length - 1] + } + const sample = (items: readonly T[], count: number) => { + const pool = [...items] + const out: T[] = [] + while (out.length < count && pool.length) out.push(pool.splice(Math.floor(random() * pool.length), 1)[0]) + return out + } + const shuffle = (items: readonly T[]) => { + const copy = [...items] + for (let index = copy.length - 1; index > 0; index--) { + const other = Math.floor(random() * (index + 1)) + ;[copy[index], copy[other]] = [copy[other], copy[index]] + } + return copy + } + const randomDate = (from: string, to: string) => { + const start = Date.parse(`${from}T12:00:00Z`) + const end = Date.parse(`${to}T12:00:00Z`) + return new Date(start + Math.floor(random() * (end - start))).toISOString().slice(0, 10) + } + + const used = new Set([ + ...Object.values(KEYS).map((key) => `${key.first} ${key.last}`), + "Nicolò Barbieri", + "Aurora Fabbri", + ]) + const people: StoredPerson[] = [] + for (let index = 0; index < 148; index++) { + let first = pick(FIRST) + let last = pick(LAST) + while (used.has(`${first} ${last}`)) { + first = pick(FIRST) + last = pick(LAST) + } + used.add(`${first} ${last}`) + const course = weighted(COURSES) + let year = + course.degree === "m" + ? pick(["1st year MSc", "2nd year MSc"]) + : pick(["1st year", "2nd year", "2nd year", "3rd year", "3rd year"]) + if (random() < 0.06) year = "Graduated" + const campus = year === "Graduated" && random() < 0.5 ? "External" : pick(course.campuses) + const f = slug(first) + const l = slug(last) + const domain = pick([ + "gmail.com", + "gmail.com", + "gmail.com", + "outlook.it", + "icloud.com", + "mail.polimi.it", + "libero.it", + ]) + const phone = `+39 3${Math.floor(random() * 10)}${Math.floor(random() * 10)} ${100 + Math.floor(random() * 900)} ${1000 + Math.floor(random() * 9000)}` + const telegram = + random() < 0.9 + ? pick([f + l, `${f}_${l[0]}`, f[0] + l, `${f}${l.slice(0, 3)}${10 + Math.floor(random() * 90)}`]) + : null + people.push({ + id: `P-${pad(index + 1, 4)}`, + family: course.family, + profile: { + firstName: first, + lastName: last, + personalEmail: `${f}.${l}@${domain}`, + phone, + telegramUsername: telegram, + telegramId: String(110000000 + Math.floor(random() * 7800000000)), + course: course.name, + year, + campus: campusOf(campus), + otherAssociations: [], + studentRepresentative: null, + }, + member: null, + admin: null, + teams: [], + census: [], + consents: { privacy: null, photo: null }, + account: { status: "none" }, + notes: random() < 0.1 ? pick(NOTES) : null, + activity: [], + start: null, + censusTelegram: null, + expected: false, + }) + } + const byId = new Map(people.map((person) => [person.id, person])) + const person = (id: string) => { + const found = byId.get(id) + if (!found) throw new Error(`Unknown person ${id}`) + return found + } + for (const [id, key] of Object.entries(KEYS)) { + const target = person(id) + target.family = key.family + Object.assign(target.profile, { + firstName: key.first, + lastName: key.last, + course: key.course, + year: key.year, + campus: campusOf(key.campus), + telegramUsername: key.tg, + personalEmail: `${slug(key.first)}.${slug(key.last)}@gmail.com`, + }) + } + person("P-0142").profile.personalEmail = "giulia.rossi.02@gmail.com" + + // Admin, team and member data accumulate here before the records get their contract shape. + type Draft = { + admin?: AdminRole & { assoc: string[]; rep: string | null } + memberAdmitted?: string + teams: TeamMembership[] + } + const drafts = new Map(people.map((p) => [p.id, { teams: [] }])) + const draft = (p: StoredPerson) => drafts.get(p.id) ?? { teams: [] } + + const familyGroups = (family: FamilyId) => FAMILIES[family].groups.filter((title) => title !== "Reti Logiche") + const makeAdmin = (p: StoredPerson, status: "active" | "former") => { + const pool = familyGroups(p.family) + const groups = random() < 0.09 ? [] : sample(pool, 1 + Math.floor(random() * Math.min(3, pool.length))) + const requested = + random() < 0.12 + ? sample( + FAMILIES[p.family].groups.filter((title) => !groups.includes(title)), + 1 + ) + : [] + const rep = random() < 0.15 ? pick(REPS) : null + const assoc = random() < 0.2 ? sample(ASSOCIATIONS, random() < 0.3 ? 2 : 1) : [] + const availableAsCapo = random() < 0.12 + const since = randomDate("2021-09-01", "2026-06-30") + const endedOn = status === "former" ? randomDate("2026-09-20", "2026-10-10") : null + const endReason = status === "former" ? pick(["Graduated", "No longer has time", "Moved abroad"]) : null + draft(p).admin = { + status, + since, + endedOn, + endReason, + groups: status === "former" ? [] : groups.map(group), + requested: status === "former" ? [] : requested.map((title) => ({ ...group(title), requestedOn: "2026-10-09" })), + availableAsCapo, + capoOfReference: null, + capoScope: null, + assoc, + rep, + } + } + const teamFields = (team: TeamId, p: StoredPerson): TeamFields => { + const first = slug(p.profile.firstName) + const last = slug(p.profile.lastName) + if (team === "it") + return { + github: first + last.slice(0, 4), + areas: sample(["Frontend", "Backend", "Bot", "Infrastructure"], 1 + Math.floor(random() * 2)), + } + if (team === "design") + return { + tools: sample( + ["Figma", "Illustrator", "Photoshop", "After Effects", "InDesign", "Premiere"], + 2 + Math.floor(random() * 2) + ), + portfolio: random() < 0.7 ? `behance.net/${first}${last}` : "", + } + if (team === "intl") + return { + languages: sample( + ["English", "Spanish", "French", "German", "Chinese", "Portuguese"], + 1 + Math.floor(random() * 2) + ), + } + const focus = + team === "hr" + ? ["Recruiting", "Onboarding", "Census", "Wellbeing"] + : ["Partnerships", "Logistics", "Sponsorships", "Communication"] + return { focus: sample(focus, 1 + Math.floor(random() * 2)) } + } + const makeTeam = ( + p: StoredPerson, + team: TeamId, + role: "member" | "lead" = "member", + since?: string, + fields?: TeamFields + ): TeamMembership => ({ + team, + role, + since: since ?? randomDate("2023-01-10", "2026-09-30"), + endedOn: null, + endReason: null, + fields: fields ?? teamFields(team, p), + }) + + const generic = shuffle(people.filter((p) => !(p.id in KEYS))) + generic.slice(0, 96).forEach((p) => makeAdmin(p, "active")) + generic.slice(96, 103).forEach((p) => makeAdmin(p, "former")) + for (const p of [...generic.slice(0, 28), ...generic.slice(96, 99), ...generic.slice(103, 127)]) { + draft(p).memberAdmitted = randomDate("2019-10-01", "2026-09-30") + } + const slots: TeamId[] = [ + "it", + "it", + "it", + "it", + "it", + "hr", + "hr", + "hr", + "design", + "design", + "design", + "design", + "design", + "design", + "intl", + "intl", + "events", + "events", + "events", + "events", + ] + ;[...generic.slice(18, 30), ...generic.slice(127, 135)].forEach((p, index) => + draft(p).teams.push(makeTeam(p, slots[index])) + ) + draft(generic[20]).teams.push(makeTeam(generic[20], "events")) + for (const [p, team] of [ + [generic[135], "it"], + [generic[100], "design"], + [generic[40], "it"], + [generic[60], "events"], + ] as const) { + const membership = makeTeam(p, team, "member", randomDate("2023-02-01", "2024-12-01")) + membership.endedOn = randomDate("2026-01-10", "2026-09-30") + membership.endReason = pick(["Graduated", "No longer has time", "Moved to another team"]) + draft(p).teams.push(membership) + } + + const link = (p: StoredPerson) => { + const isMember = draft(p).memberAdmitted !== undefined + const account: Account = { + status: "linked", + email: isMember ? m365Address(p.profile.firstName, p.profile.lastName) : (p.profile.personalEmail ?? ""), + via: isMember ? "Microsoft 365" : pick(["Telegram", "Google"]), + linkedOn: randomDate("2026-02-01", "2026-10-10"), + how: pick(["Invite accepted", "Matched on verified Telegram account", "Linked by Luca Ferri"]), + } + p.account = account + } + const invite = (p: StoredPerson, expired: boolean) => { + const sentOn = expired ? randomDate("2026-09-08", "2026-09-24") : randomDate("2026-09-30", "2026-10-13") + p.account = { + status: "invite", + email: p.profile.personalEmail ?? "", + sentOn, + expiresOn: addDays(sentOn, 14), + sentBy: pick(["Luca Ferri", "Marta Colombo"]), + } + } + ;[...generic.slice(0, 10), ...generic.slice(18, 30), ...generic.slice(103, 109), ...generic.slice(127, 132)].forEach( + link + ) + ;[...generic.slice(10, 18), ...generic.slice(109, 113)].forEach((p) => + invite(p, p === generic[11] || p === generic[110]) + ) + const review = [generic[30], generic[31], generic[113], generic[114], generic[132]] + review[0].profile.firstName = "Marco" + review[0].profile.lastName = "Bianchini" + review[0].profile.telegramUsername = "marcobianchi" + review[2].profile.personalEmail = review[1].profile.personalEmail + + // Key people. + const keyAdmin = ( + id: string, + since: string, + groups: string[], + extra: Partial = {} + ) => { + const p = person(id) + makeAdmin(p, "active") + const admin = draft(p).admin + if (!admin) return + Object.assign( + admin, + { since, groups: groups.map(group), requested: [], rep: null, assoc: [], availableAsCapo: false }, + extra + ) + } + keyAdmin("P-0142", "2024-10-03", ["Fondamenti di Informatica", "Basi di Dati"], { + requested: [{ ...group("Reti Logiche"), requestedOn: "2026-10-09" }], + availableAsCapo: true, + rep: "Consiglio di Corso di Studio", + assoc: ["Skyward"], + }) + keyAdmin("P-0098", "2022-09-20", ["Fondamenti di Informatica"], { assoc: ["JEMP"] }) + keyAdmin("P-0034", "2023-03-01", ["Basi di Dati"]) + keyAdmin("P-0115", "2025-09-25", ["Fondamenti di Informatica", "Basi di Dati"], { rep: "Consiglio degli Studenti" }) + keyAdmin("P-0063", "2025-02-11", ["Ricerca Operativa", "Statistica"]) + keyAdmin("P-0021", "2023-10-12", ["Segnali e Sistemi"]) + keyAdmin("P-0088", "2024-03-05", ["Fisica Tecnica", "Termodinamica Applicata"]) + keyAdmin("P-0076", "2026-03-02", ["Aerodinamica"]) + const chiara = person("P-0131") + makeAdmin(chiara, "former") + const chiaraAdmin = draft(chiara).admin + if (chiaraAdmin) Object.assign(chiaraAdmin, { since: "2022-10-01", endedOn: "2026-07-10", endReason: "Graduated" }) + draft(chiara).teams.push({ + ...makeTeam(chiara, "design", "member", "2023-01-15"), + endedOn: "2026-07-10", + endReason: "Graduated", + }) + + const admitted: [string, string][] = [ + ["P-0098", "2022-11-03"], + ["P-0034", "2023-03-20"], + ["P-0007", "2021-10-15"], + ["P-0021", "2023-11-02"], + ["P-0045", "2024-02-09"], + ["P-0052", "2024-10-21"], + ] + for (const [id, date] of admitted) draft(person(id)).memberAdmitted = date + draft(person("P-0034")).teams.push( + makeTeam(person("P-0034"), "it", "lead", "2023-02-10", { + github: "saragalli", + areas: ["Backend", "Infrastructure"], + }) + ) + draft(person("P-0142")).teams.push( + makeTeam(person("P-0142"), "it", "member", "2025-03-04", { github: "giuliarossi", areas: ["Frontend", "Bot"] }) + ) + draft(person("P-0115")).teams.push( + makeTeam(person("P-0115"), "it", "member", "2025-10-01", { github: "mconti", areas: ["Bot"] }) + ) + draft(person("P-0021")).teams.push( + makeTeam(person("P-0021"), "hr", "lead", "2024-01-15", { focus: ["Recruiting", "Census"] }) + ) + draft(person("P-0045")).teams.push( + makeTeam(person("P-0045"), "design", "lead", "2024-03-01", { + tools: ["Figma", "Illustrator", "After Effects"], + portfolio: "behance.net/alessiafontana", + }) + ) + draft(person("P-0052")).teams.push( + makeTeam(person("P-0052"), "intl", "lead", "2024-11-05", { languages: ["English", "Spanish"] }) + ) + draft(person("P-0007")).teams.push( + makeTeam(person("P-0007"), "events", "lead", "2022-02-01", { focus: ["Partnerships", "Sponsorships"] }) + ) + for (const id of ["P-0098", "P-0034", "P-0115", "P-0063", "P-0007", "P-0021", "P-0045", "P-0052"]) link(person(id)) + const davideAccount = person("P-0098").account + if (davideAccount.status === "linked") davideAccount.how = "Invite accepted" + const matteoAccount = person("P-0115").account + if (matteoAccount.status === "linked") matteoAccount.how = "Matched on verified Telegram account" + person("P-0142").account = { + status: "invite", + email: "giulia.rossi.02@gmail.com", + sentOn: "2026-10-06", + expiresOn: "2026-10-20", + sentBy: "Luca Ferri", + } + invite(person("P-0076"), true) + + // Capo Admins: Davide for Informatica, the first generic active admin of every other family. + const davideAdmin = draft(person("P-0098")).admin + if (davideAdmin) davideAdmin.capoScope = FAMILIES.inf.scope + const capoOf = new Map([["inf", person("P-0098")]]) + for (const family of FAMILY_IDS.slice(1)) { + const capo = generic.find( + (p) => p.family === family && draft(p).admin?.status === "active" && !draft(p).admin?.capoScope + ) + const capoAdmin = capo && draft(capo).admin + if (capo && capoAdmin) { + capoAdmin.capoScope = FAMILIES[family].scope + capoOf.set(family, capo) + } + } + for (const p of people) { + const admin = draft(p).admin + const capo = capoOf.get(p.family) + if (admin && capo && capo !== p) { + admin.capoOfReference = { id: capo.id, name: `${capo.profile.firstName} ${capo.profile.lastName}` } + } + } + + // Members: numbers in admission order, then statuses and fees. + const members = people + .filter((p) => draft(p).memberAdmitted !== undefined) + .sort((a, b) => ((draft(a).memberAdmitted ?? "") < (draft(b).memberAdmitted ?? "") ? -1 : 1)) + let number = 287 + for (const p of members) { + number += 1 + Math.floor(random() * 4) + p.member = { + number: pad(number, 4), + status: "active", + admittedOn: draft(p).memberAdmitted ?? SAMPLE_TODAY, + fee: null, + m365Email: m365Address(p.profile.firstName, p.profile.lastName), + } + } + const setStatus = (list: StoredPerson[], status: "former" | "expired" | "suspended") => { + for (const p of list) if (p.member) p.member.status = status + } + setStatus(generic.slice(0, 4), "former") + setStatus([...generic.slice(4, 7), ...generic.slice(103, 107)], "expired") + setStatus(generic.slice(107, 109), "suspended") + for (const p of members) { + const member = p.member + if (!member) continue + if (member.status === "active") { + const roll = random() * 100 + member.fee = roll < 58 ? "paid" : roll < 72 ? "receipt" : roll < 96 ? "due" : "unpaid" + } else member.fee = member.status === "former" ? null : "unpaid" + } + for (const id of ["P-0098", "P-0034", "P-0007"]) { + const member = person(id).member + if (member) member.fee = "paid" satisfies FeeStatus + } + + // Admin and team roles take their contract shape. + for (const p of people) { + const d = draft(p) + if (d.admin) { + const { assoc, rep, ...admin } = d.admin + p.admin = admin + p.profile.otherAssociations = assoc + p.profile.studentRepresentative = rep + } + p.teams = d.teams + } + + // Census and consents. + const hasRole = (p: StoredPerson) => + (p.member !== null && p.member.status !== "former") || + p.admin?.status === "active" || + p.teams.some((team) => !team.endedOn) + for (const p of people) { + const starts = [p.admin?.since, p.member?.admittedOn, ...p.teams.map((team) => team.since)] + .filter((date): date is string => !!date) + .sort() + p.start = starts[0] ?? null + const leftInCensus = p.admin?.status === "former" && (p.admin.endedOn ?? "") >= "2026-09-15" + p.expected = hasRole(p) || leftInCensus + const census: CensusEntry[] = [] + if (p.start && p.start < "2025-09-15" && random() < 0.9) { + census.push({ + year: "2025/26", + answer: "continues", + answeredOn: randomDate("2025-09-16", "2025-10-25"), + source: "forms", + declaredMember: p.member !== null, + reminders: 0, + }) + } + if (leftInCensus) { + census.push({ + year: "2026/27", + answer: "leaving", + answeredOn: addDays(p.admin?.endedOn ?? SAMPLE_TODAY, -3), + source: "forms", + declaredMember: p.member !== null, + reminders: 0, + }) + } else if (p.expected && random() < 0.71) { + census.push({ + year: "2026/27", + answer: "continues", + answeredOn: randomDate("2026-09-16", "2026-10-12"), + source: p.account.status === "linked" && random() < 0.5 ? "dashboard" : "forms", + declaredMember: p.member !== null && p.member.status !== "former", + reminders: 0, + }) + } else if (p.expected) { + census.push({ + year: "2026/27", + answer: null, + answeredOn: null, + source: null, + declaredMember: null, + reminders: random() < 0.6 ? 1 + Math.floor(random() * 2) : 0, + }) + } + p.census = census + const consentOn = census.find((entry) => entry.answeredOn)?.answeredOn ?? p.start + if (consentOn) { + const roll = random() + p.consents = { + privacy: { version: consentOn >= "2025-09-01" ? "3" : "2", acceptedOn: consentOn }, + photo: + roll < 0.8 ? { status: "granted", on: consentOn } : roll < 0.95 ? { status: "denied", on: consentOn } : null, + } + } + } + const setAnswer = (p: StoredPerson, entry: CensusEntry | null) => { + p.census = p.census.filter((existing) => existing.year !== "2026/27") + if (entry) p.census.push(entry) + } + const leaver = generic[135] + leaver.census = [ + { + year: "2025/26", + answer: "leaving", + answeredOn: "2025-10-02", + source: "forms", + declaredMember: false, + reminders: 0, + }, + ] + for (const team of leaver.teams) { + team.endedOn = "2025-10-20" + team.endReason = "Answered No in the 2025/26 census" + } + for (const p of [generic[40], generic[41], generic[42]]) { + if (!p.member) + setAnswer(p, { + year: "2026/27", + answer: "continues", + answeredOn: randomDate("2026-09-16", "2026-10-12"), + source: "forms", + declaredMember: true, + reminders: 0, + }) + } + const giulia = person("P-0142") + giulia.census = [ + { + year: "2025/26", + answer: "continues", + answeredOn: "2025-09-30", + source: "forms", + declaredMember: false, + reminders: 0, + }, + { + year: "2026/27", + answer: "continues", + answeredOn: "2026-09-21", + source: "forms", + declaredMember: true, + reminders: 0, + }, + ] + giulia.consents = { + privacy: { version: "3", acceptedOn: "2026-09-21" }, + photo: { status: "granted", on: "2026-09-21" }, + } + giulia.notes = "Strong on the bot codebase; Sara suggests her for IT co-lead next year." + for (const id of ["P-0115", "P-0063"]) + setAnswer(person(id), { + year: "2026/27", + answer: null, + answeredOn: null, + source: null, + declaredMember: null, + reminders: 1, + }) + setAnswer(person("P-0098"), { + year: "2026/27", + answer: "continues", + answeredOn: "2026-09-17", + source: "dashboard", + declaredMember: true, + reminders: 0, + }) + setAnswer(person("P-0034"), { + year: "2026/27", + answer: "continues", + answeredOn: "2026-09-18", + source: "dashboard", + declaredMember: true, + reminders: 0, + }) + setAnswer(person("P-0088"), { + year: "2026/27", + answer: "continues", + answeredOn: "2026-09-25", + source: "forms", + declaredMember: false, + reminders: 0, + }) + person("P-0088").censusTelegram = "marco_bianchi" + for (const p of people) p.census.sort((a, b) => (a.year < b.year ? 1 : -1)) + + giulia.activity = [ + { actor: "Luca Ferri", text: "sent an invite to giulia.rossi.02@gmail.com", at: "2026-10-06T18:42:00+02:00" }, + { actor: "Giulia Rossi", text: "asked to join Reti Logiche", at: "2026-10-09T21:30:00+02:00" }, + { + actor: "Census import", + text: "added the 2026/27 answer “Continues” from Modulo Volontari 2026/27", + at: "2026-10-01T10:05:00+02:00", + }, + { actor: "Telegram bot", text: "detected Giulia as admin of Basi di Dati", at: "2026-09-24T09:12:00+02:00" }, + { actor: "Sara Galli", text: "added Giulia to IT as Member", at: "2025-03-04T16:20:00+01:00" }, + ] + + // Linking reviews. + const name = (p: StoredPerson) => `${p.profile.firstName} ${p.profile.lastName}` + const ref = (p: StoredPerson) => ({ id: p.id, name: name(p) }) + person("P-0088").account = { + status: "review", + reviewId: "r1", + issue: "Telegram account already linked to another record", + } + const reviews: Review[] = [ + { + id: "r1", + kind: "telegram-conflict", + since: "2026-10-12", + account: { + name: "Marco Bianchi", + email: "marco.bianchi.pc@gmail.com", + telegram: "@marcobianchi", + via: "Telegram", + }, + record: ref(person("P-0088")), + other: ref(review[0]), + message: null, + }, + { + id: "r2", + kind: "ask-to-be-added", + since: "2026-10-13", + account: { + name: "Nicolò Barbieri", + email: "nicolo.barbieri@mail.polimi.it", + telegram: "@nicobarbieri", + via: "Google", + }, + record: null, + other: null, + message: "I administer the Analisi Matematica 2 group since September.", + }, + { + id: "r3", + kind: "two-records", + since: "2026-10-10", + account: { + name: name(review[1]), + email: review[1].profile.personalEmail ?? "", + telegram: review[1].profile.telegramUsername ? `@${review[1].profile.telegramUsername}` : null, + via: "Google", + }, + record: ref(review[1]), + other: ref(review[2]), + message: null, + }, + { + id: "r4", + kind: "not-me", + since: "2026-10-11", + account: { + name: `${review[3].profile.firstName} ${LAST[(LAST.indexOf(review[3].profile.lastName) + 7) % LAST.length]}`, + email: `${slug(review[3].profile.firstName)}.x@outlook.it`, + telegram: null, + via: "Google", + }, + record: ref(review[3]), + other: null, + message: null, + }, + { + id: "r5", + kind: "ask-to-be-added", + since: "2026-10-09", + 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.", + }, + { + id: "r6", + kind: "name-only", + since: "2026-10-08", + account: { + name: name(review[4]), + email: `${slug(review[4].profile.firstName)}${slug(review[4].profile.lastName)}@icloud.com`, + telegram: `@${slug(review[4].profile.firstName)}_real`, + via: "Telegram", + }, + record: ref(review[4]), + other: null, + message: null, + }, + ] + const reviewIssue = new Map([ + ["r1", "Telegram account already linked to another record"], + ["r3", "Two records match one sign-in"], + ["r4", "Said “This isn't me” at first sign-in"], + ["r6", "Matched by name only"], + ]) + for (const item of reviews) { + const target = item.record && byId.get(item.record.id) + const issue = reviewIssue.get(item.id) + if (target && issue) target.account = { status: "review", reviewId: item.id, issue } + } + review[0].account = { status: "review", reviewId: "r1", issue: "Its Telegram account is claimed by another sign-in" } + + const informatics = people.filter((p) => p.family === "inf" && p.admin?.status === "active" && !(p.id in KEYS)) + const joinRequests: JoinRequest[] = [ + { id: "j1", personId: "P-0142", group: "Reti Logiche", requestedOn: "2026-10-09" }, + { id: "j2", personId: (informatics[0] ?? giulia).id, group: "Reti Logiche", requestedOn: "2026-10-11" }, + { id: "j3", personId: (informatics[1] ?? giulia).id, group: "Basi di Dati", requestedOn: "2026-10-12" }, + ] + + return { people, byId, reviews, joinRequests } +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 1389eb33..12bb9f8b 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -9,7 +9,9 @@ export type DashboardUser = { export type DashboardAccess = | { status: "authorized"; user: DashboardUser; permissions: string[] } - /** Signed in without `admin:access`; `stale` when the backend's permission snapshot is out of date. */ + /** + * Signed in without `admin:access` or `people:self`; `stale` when the backend's permission snapshot is out of date. + */ | { status: "forbidden"; user: DashboardUser; stale: boolean } export type DashboardAccessState = DashboardAccess | { status: "unauthenticated" } diff --git a/src/server/auth.middleware.ts b/src/server/auth.middleware.ts index fb13dd82..2e43690b 100644 --- a/src/server/auth.middleware.ts +++ b/src/server/auth.middleware.ts @@ -38,8 +38,11 @@ export const authenticatedMiddleware = createMiddleware({ type: "function" }) return next({ context: { session: context.session } }) }) -/** Reads: the user holds `admin:access`. The backend checks the permission of every procedure on top. */ -export const adminMiddleware = createMiddleware({ type: "function" }) +/** + * Any dashboard user: `admin:access`, or `people:self` alone for the restricted People view. Functions add their own + * permission on top; the backend checks every procedure again. + */ +export const dashboardUserMiddleware = createMiddleware({ type: "function" }) .middleware([authenticatedMiddleware]) .server(async ({ next, context }) => { const { loadAccess } = await import("@/server/auth.server") @@ -48,10 +51,77 @@ export const adminMiddleware = createMiddleware({ type: "function" }) return next({ context: { user: access.user, permissions: access.permissions } }) }) +/** Reads: the user holds `admin:access`. The backend checks the permission of every procedure on top. */ +export const adminMiddleware = createMiddleware({ type: "function" }) + .middleware([dashboardUserMiddleware]) + .server(({ next, context }) => { + if (!hasPermission(context.permissions, "admin:access")) throw redirect({ to: "/unauthorized" }) + return next() + }) + function requirePermission(permissions: readonly string[], permission: Permission) { if (!hasPermission(permissions, permission)) throw new Error("UNAUTHORIZED") } +/** People: the signed-in person's own area (My area), and the Capo Admin and Team Lead views the backend scopes. */ +export const peopleSelfMiddleware = createMiddleware({ type: "function" }) + .middleware([dashboardUserMiddleware]) + .server(({ next, context }) => { + requirePermission(context.permissions, "people:self") + return next() + }) + +/** People: every record, admins, teams, census and linking, with contacts and HR notes. */ +export const peopleReadMiddleware = createMiddleware({ type: "function" }) + .middleware([dashboardUserMiddleware]) + .server(({ next, context }) => { + requirePermission(context.permissions, "people:read") + return next() + }) + +/** People: add and edit people, admin roles, team memberships, the census and account linking. */ +export const peopleWriteMiddleware = createMiddleware({ type: "function" }) + .middleware([dashboardUserMiddleware]) + .server(({ next, context }) => { + requirePermission(context.permissions, "people:write") + return next() + }) + +/** + * People › Teams writes: `people:write`, or a Team Lead (`people:self`) on their own team, which only the backend + * can tell from the record. + */ +export const peopleTeamWriteMiddleware = createMiddleware({ type: "function" }) + .middleware([dashboardUserMiddleware]) + .server(({ next, context }) => { + if (!hasPermission(context.permissions, "people:write")) requirePermission(context.permissions, "people:self") + return next() + }) + +/** People › Member register: read. */ +export const peopleMembersReadMiddleware = createMiddleware({ type: "function" }) + .middleware([dashboardUserMiddleware]) + .server(({ next, context }) => { + requirePermission(context.permissions, "people:members:read") + return next() + }) + +/** People › Member register: add members, change status and number, export. */ +export const peopleMembersWriteMiddleware = createMiddleware({ type: "function" }) + .middleware([dashboardUserMiddleware]) + .server(({ next, context }) => { + requirePermission(context.permissions, "people:members:write") + return next() + }) + +/** People: appoint Capo Admins and Team Leads. */ +export const peopleAppointMiddleware = createMiddleware({ type: "function" }) + .middleware([dashboardUserMiddleware]) + .server(({ next, context }) => { + requirePermission(context.permissions, "people:appoint") + return next() + }) + /** Telegram grants: create and interrupt. */ export const grantsWriteMiddleware = createMiddleware({ type: "function" }) .middleware([adminMiddleware]) diff --git a/src/server/auth.server.ts b/src/server/auth.server.ts index a2651e38..260d52fe 100644 --- a/src/server/auth.server.ts +++ b/src/server/auth.server.ts @@ -14,7 +14,7 @@ import { refreshTokens, revokeRefreshToken, } from "@/server/oidc.server" -import { hasPermission, isAgentModeEnabled, PERMISSIONS } from "@/server/permissions" +import { agentPermissions, canEnterDashboard, isAgentModeEnabled } from "@/server/permissions" import { AuthUnavailableError, ensureFreshSession, @@ -167,7 +167,11 @@ function cacheAccess(key: string, access: DashboardAccess, now: number) { */ export async function loadAccess(session: RequestSession, backend: BackendClient): Promise { if (isAgentMode()) { - return { status: "authorized", user: { ...session.user, telegramId: "1" }, permissions: [...PERMISSIONS] } + return { + status: "authorized", + user: { ...session.user, telegramId: "1" }, + permissions: agentPermissions(env.AGENT_PERMISSIONS), + } } const now = Date.now() const cached = accessCache.get(session.key) @@ -175,7 +179,7 @@ export async function loadAccess(session: RequestSession, backend: BackendClient const result = await backend.me.access.query() const user = { ...session.user, telegramId: result.telegramId } - const access: DashboardAccess = hasPermission(result.permissions, "admin:access") + const access: DashboardAccess = canEnterDashboard(result.permissions) ? { status: "authorized", user, permissions: result.permissions } : { status: "forbidden", user, stale: result.stale } cacheAccess(session.key, access, now) diff --git a/src/server/permissions.ts b/src/server/permissions.ts index 72cb27ef..ae5b3484 100644 --- a/src/server/permissions.ts +++ b/src/server/permissions.ts @@ -18,6 +18,12 @@ export const PERMISSIONS = [ "azure:members:create", "azure:groups:read", "azure:groups:write", + "people:self", + "people:read", + "people:write", + "people:members:read", + "people:members:write", + "people:appoint", ] as const export type Permission = (typeof PERMISSIONS)[number] @@ -26,6 +32,24 @@ export function hasPermission(permissions: readonly string[], permission: Permis return permissions.includes(permission) } +/** + * Whether these permissions open the dashboard: `admin:access` for the full dashboard, or `people:self` alone for the + * restricted view (My area, plus Capo Admin or a team page when the person's record gives them that role). + */ +export function canEnterDashboard(permissions: readonly string[]) { + return hasPermission(permissions, "admin:access") || hasPermission(permissions, "people:self") +} + +/** + * Agent mode's permissions: every one, or the comma-separated `AGENT_PERMISSIONS` (development only) to preview a + * restricted role. Unknown names are dropped. + */ +export function agentPermissions(list: string | undefined): Permission[] { + if (!list) return [...PERMISSIONS] + const requested = list.split(",").map((permission) => permission.trim()) + return PERMISSIONS.filter((permission) => requested.includes(permission)) +} + export function isAgentModeEnabled(nodeEnv: string, agentMode: boolean) { return nodeEnv === "development" && agentMode } From 7dd9cb48c8b219ba3dbead4b5b4c0f94526bcc51 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 12:28:23 +0000 Subject: [PATCH 03/24] feat(people): add People navigation, routes and agent-mode API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - People service (Overview, Member register, Admins, Teams, Census, Linking), Telegram › Capo Admin and Account › My area / Sign-in & security. - Sections open by permission, admin:access, or a role on the person's own record (Capo Admin, Team Lead); /dashboard redirects pages a user can't open. - PeopleApi with an agent-mode implementation over the sample data and a backend adapter that parses every response with the contract. - Placeholder routes for every People page; pages land in the next phases. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ti91VKqKpduLAF376TunMn --- README.md | 2 + docs/people/contract.md | 6 +- docs/people/plan.md | 28 +- src/components/shell/index.ts | 6 +- src/components/shell/mobile-nav.tsx | 14 +- src/components/shell/nav.ts | 203 +++- src/components/shell/rail.tsx | 13 +- src/components/shell/use-can.ts | 26 +- src/features/dashboard/overview-page.tsx | 12 +- src/features/people/page-placeholder.tsx | 20 + src/features/people/people.api.server.ts | 184 ++++ src/features/people/people.contract.ts | 132 +++ src/features/people/people.fake.server.ts | 953 ++++++++++++++++++ src/features/people/people.fixtures.server.ts | 2 +- src/features/people/people.functions.ts | 10 + src/routeTree.gen.ts | 277 ++++- src/routes/dashboard.tsx | 21 +- .../{account.tsx => account/index.tsx} | 2 +- src/routes/dashboard/account/me.tsx | 7 + src/routes/dashboard/people/$personId.tsx | 7 + src/routes/dashboard/people/admins.tsx | 7 + .../dashboard/people/census/$campaignId.tsx | 7 + src/routes/dashboard/people/census/index.tsx | 7 + src/routes/dashboard/people/index.tsx | 7 + src/routes/dashboard/people/linking.tsx | 7 + src/routes/dashboard/people/members.tsx | 7 + src/routes/dashboard/people/teams/$teamId.tsx | 7 + src/routes/dashboard/people/teams/index.tsx | 7 + src/routes/dashboard/telegram/capo.tsx | 7 + src/server/backend.server.ts | 50 +- tests/server-security.test.mjs | 3 +- 31 files changed, 1952 insertions(+), 89 deletions(-) create mode 100644 src/features/people/page-placeholder.tsx create mode 100644 src/features/people/people.api.server.ts create mode 100644 src/features/people/people.fake.server.ts create mode 100644 src/features/people/people.functions.ts rename src/routes/dashboard/{account.tsx => account/index.tsx} (87%) create mode 100644 src/routes/dashboard/account/me.tsx create mode 100644 src/routes/dashboard/people/$personId.tsx create mode 100644 src/routes/dashboard/people/admins.tsx create mode 100644 src/routes/dashboard/people/census/$campaignId.tsx create mode 100644 src/routes/dashboard/people/census/index.tsx create mode 100644 src/routes/dashboard/people/index.tsx create mode 100644 src/routes/dashboard/people/linking.tsx create mode 100644 src/routes/dashboard/people/members.tsx create mode 100644 src/routes/dashboard/people/teams/$teamId.tsx create mode 100644 src/routes/dashboard/people/teams/index.tsx create mode 100644 src/routes/dashboard/telegram/capo.tsx diff --git a/README.md b/README.md index efd41fdc..e05532e6 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,8 @@ Set the following runtime variables (the build does not need secrets): | `BACKEND_RESOURCE` | Backend OAuth resource/audience; defaults to `https://backend.internal.polinetwork.org`. Sent on every authorize, code exchange and refresh request. | | `REDIS_URL` | Dedicated dashboard Redis connection. Required outside development; development defaults to `redis://localhost:6379`. Never point to shared backend Redis. | | `SESSION_SECRET` | Independent random secret of at least 32 characters, encrypting the 10-minute state/nonce/PKCE cookie. Rotation invalidates pending login attempts. | +| `AGENT_PERMISSIONS` | Development only. Comma-separated permissions for the agent-mode user (default: all). `people:self` alone previews the restricted People view. | +| `AGENT_PERSON` | Development only. The agent-mode user's People record (default `P-0007`, Direttivo). `P-0098` is a Capo Admin, `P-0034` the IT Team Lead, `P-0115` an IT member; `match:P-0115` previews the first sign-in, `none` a sign-in without a record. | | `AGENT_MODE` | Fake all-permission user in development only. Backend must be a local fake or local legacy test instance. Production ignores this mode. | | `PORT` | Local listening port (3001 by default); use a port ≥10000 for agent previews. | | `TEST_REDIS_URL` | Opt-in disposable Redis for integration tests. Tests create/delete only UUID session keys. | diff --git a/docs/people/contract.md b/docs/people/contract.md index 6be375ce..5a031696 100644 --- a/docs/people/contract.md +++ b/docs/people/contract.md @@ -1,8 +1,10 @@ # People area: backend contract The dashboard calls these procedures under `people.*` on the tRPC backend (`@polinetwork/backend`). They do not exist -yet: this file is the spec for the backend project, and the dashboard's own copy of the types lives in -`src/features/people/people.contract.ts` (the source of truth once code exists; keep both in sync). In agent mode the +yet: this file is the spec for the backend project. **`src/features/people/people.contract.ts` is the source of +truth** for every shape and input (zod schemas); where this file differs, the code wins. Notably, list procedures +return full rows (`PersonRow`: profile, member, admin, teams, census, consents, account) rather than the summary below, +and `people.me.roles` returns `{ capoScope, leads }` for the navigation. In agent mode the dashboard serves the same shapes from `src/features/people/people.fixtures.server.ts`. Every procedure checks permissions server-side; the "Needs" column is the minimum. Dates are ISO `YYYY-MM-DD` strings diff --git a/docs/people/plan.md b/docs/people/plan.md index a344fb5f..6309f754 100644 --- a/docs/people/plan.md +++ b/docs/people/plan.md @@ -49,17 +49,20 @@ src/routes/dashboard/account/ me.tsx (My area), index.tsx (today's Account page ### 1. Foundation -- [ ] Permissions: add the six `people:*` permissions; middlewares `peopleReadMiddleware`, `peopleWriteMiddleware`, - `peopleMembersReadMiddleware`, `peopleMembersWriteMiddleware`, `peopleAppointMiddleware`, `peopleSelfMiddleware` -- [ ] Access: `people:self` admits to the dashboard; guard existing routes for `admin:access`; `AGENT_PERMISSIONS` / - `AGENT_PERSON` -- [ ] `people.contract.ts`, `people.fixtures.server.ts` (148 people, same distributions as the prototype), - `people.source.server.ts` -- [ ] Nav: People service and sections, Telegram › Capo Admin, Account as a two-section service (My area, Sign-in & - security); `PageMatch` kinds and `matchPath`; rail/panel/pills/bottom nav filtering by permission and by record - role (Capo Admin, Team Lead) -- [ ] Route files with `pendingComponent` skeletons for every page -- [ ] Tests registry updated; all four checks green +- [x] Permissions: add the six `people:*` permissions; middlewares `peopleReadMiddleware`, `peopleWriteMiddleware`, + `peopleTeamWriteMiddleware`, `peopleMembersReadMiddleware`, `peopleMembersWriteMiddleware`, + `peopleAppointMiddleware`, `peopleSelfMiddleware` (all built on `dashboardUserMiddleware`) +- [x] Access: `people:self` admits to the dashboard; `adminMiddleware` requires `admin:access` explicitly; the + `/dashboard` route redirects pages the user can't open (`canOpenPage`); `AGENT_PERMISSIONS` / `AGENT_PERSON` +- [x] `people.contract.ts` (zod; source of truth for shapes), `people.fixtures.server.ts`, `people.fake.server.ts` + (agent-mode API), `people.api.server.ts` (`PeopleApi`; backend adapter through an untyped tRPC client whose + responses are parsed with the contract) +- [x] Nav: People service and sections, Telegram › Capo Admin, Account as a two-section service (My area, Sign-in & + security); `PageMatch` kinds and `matchPath`; `sectionVisible` filters by permission, `admin:access` and record + role (Capo Admin, Team Lead); the logo and avatar lead to My area for `people:self`-only users +- [x] Route files for every page (placeholder component `PagePlaceholder` until each phase replaces it; add + `pendingComponent` skeletons with the real pages) +- [x] Tests registry updated; all four checks green ### 2. Overview and person record @@ -119,3 +122,6 @@ src/routes/dashboard/account/ me.tsx (My area), index.tsx (today's Account page Newest last. One line per commit or session: date, what was done, what is next. - 2026-10-14: phase 0 done (handoff docs, prototype copy, draft PR). Next: phase 1. +- 2026-10-14: phase 1 done. Verified in agent mode: Direttivo reaches People; `AGENT_PERMISSIONS=people:self` with + `AGENT_PERSON=P-0098` (Capo Admin) reaches only Telegram › Capo Admin and Account; with `P-0034` (IT Team Lead) + only People › Teams and Account. Next: phase 2 (Overview and person record). diff --git a/src/components/shell/index.ts b/src/components/shell/index.ts index 93036f54..ad6193d8 100644 --- a/src/components/shell/index.ts +++ b/src/components/shell/index.ts @@ -2,7 +2,11 @@ export { AccountAvatar, type ShellUser } from "./account-avatar" export { type DashboardPath, documentTitle, + canOpenPage, isDeepPage, + isRailService, + NO_PEOPLE_ROLES, + type PeopleRoles, matchPath, type PageKind, type PageMatch, @@ -36,5 +40,5 @@ export { ServiceGlyph } from "./service-glyph" export { DashboardShell, type DashboardShellProps } from "./shell" export { type Theme, themeToggleLabel, useTheme } from "./theme" export { appToast } from "./toast" -export { useCan } from "./use-can" +export { useCan, useHasAdminAccess, usePeopleRoles } from "./use-can" export { useSignOut } from "./use-sign-out" diff --git a/src/components/shell/mobile-nav.tsx b/src/components/shell/mobile-nav.tsx index 194a54d5..56ccc879 100644 --- a/src/components/shell/mobile-nav.tsx +++ b/src/components/shell/mobile-nav.tsx @@ -7,11 +7,11 @@ import { Button } from "@/components/ui/button" import { cn } from "@/lib/utils" import { AccountAvatar, type ShellUser } from "./account-avatar" -import { type DashboardPath, type PageMatch, type Service, serviceFor } from "./nav" +import { type DashboardPath, isRailService, type PageMatch, type Service, serviceFor } from "./nav" import { OpenReportsDot } from "./rail" import { ServiceGlyph } from "./service-glyph" import { themeToggleLabel, useTheme } from "./theme" -import { useVisibleServices } from "./use-can" +import { useHasAdminAccess, useVisibleServices } from "./use-can" const barButton = "size-10 text-(--pn-fg-muted) hover:text-(--pn-fg) active:not-aria-[haspopup]:translate-y-0" @@ -30,6 +30,8 @@ export function MobileTopBar({ match, onOpenPalette, user, className }: MobileTo const service = serviceFor(match) const { theme, toggleTheme } = useTheme() const title = service.id === "overview" ? "PoliNetwork Admin" : service.title + const hasAdminAccess = useHasAdminAccess() + const accountPath = useVisibleServices().find((candidate) => candidate.id === "account")?.path ?? "/dashboard/account" return (
@@ -62,7 +64,7 @@ export function MobileTopBar({ match, onOpenPalette, user, className }: MobileTo service.sections.length > 0) + const services = useVisibleServices().filter(isRailService) const activeId = serviceFor(match).id return ( diff --git a/src/components/shell/nav.ts b/src/components/shell/nav.ts index 3403b81f..7bfbdc6f 100644 --- a/src/components/shell/nav.ts +++ b/src/components/shell/nav.ts @@ -2,12 +2,18 @@ import { BookOpen, CircleCheck, CircleQuestionMark, + ClipboardList, Cloud, + Crown, Flag, FolderKanban, FolderTree, Globe, + IdCard, Inbox, + KeyRound, + LayoutList, + Link2, LayoutDashboard, type LucideIcon, MessageCircle, @@ -15,6 +21,7 @@ import { Send, ShieldCheck, Tags, + UserCog, UserRound, Users, UsersRound, @@ -25,12 +32,12 @@ import telegramLogo from "@/assets/svg/telegram.svg" import whatsappLogo from "@/assets/svg/whatsapp.svg" import { urlSegmentsToLabelPath } from "@/features/group-labels/label-tree" import type { FileRoutesByTo } from "@/routeTree.gen" -import type { Permission } from "@/server/permissions" +import { hasPermission, type Permission } from "@/server/permissions" /** Every navigable dashboard route path, as TanStack `Link`'s `to` accepts it. */ export type DashboardPath = Extract -export type ServiceId = "overview" | "telegram" | "whatsapp" | "m365" | "web" | "reports" | "account" +export type ServiceId = "overview" | "people" | "telegram" | "whatsapp" | "m365" | "web" | "reports" | "account" export type Section = { id: string @@ -42,8 +49,23 @@ export type Section = { queryable?: boolean permission?: Permission anyPermission?: readonly Permission[] + /** + * Opens the section from the signed-in person's own record, without (or instead of) a permission: Capo Admins see + * Telegram › Capo Admin, Team Leads see People › Teams. + */ + role?: PeopleRole + /** Open with `people:self` alone (Account). Every other section also needs `admin:access`. */ + selfService?: boolean } +/** Roles that come from the person's record (`people.me.roles`), not from IdP permissions. */ +export type PeopleRole = "capo" | "lead" + +/** What the signed-in person's record grants them in the navigation. */ +export type PeopleRoles = { capoScope: string | null; leads: readonly string[] } + +export const NO_PEOPLE_ROLES: PeopleRoles = { capoScope: null, leads: [] } + /** * `path` is where the service lands (its first section, or its own page for Overview and Account). `logo` is the * branded mark that replaces `icon` wherever the service is named (rail, panel, bottom nav, palette, Overview); @@ -62,6 +84,17 @@ export type Service = { export type PageMatch = | { kind: "overview" } | { kind: "account" } + | { kind: "account-me" } + | { kind: "people-overview" } + | { kind: "people-person"; personId: string } + | { kind: "people-members" } + | { kind: "people-admins" } + | { kind: "people-teams" } + | { kind: "people-team"; teamId: string } + | { kind: "people-census" } + | { kind: "people-campaign"; campaignId: string } + | { kind: "people-linking" } + | { kind: "telegram-capo" } | { kind: "telegram-users" } | { kind: "telegram-user"; userId: number } | { kind: "telegram-groups" } @@ -96,8 +129,76 @@ const account: Service = { title: "Account", short: "Account", icon: UserRound, - path: "/dashboard/account", - sections: [], + path: "/dashboard/account/me", + sections: [ + { + id: "me", + title: "My area", + icon: UserRound, + path: "/dashboard/account/me", + permission: "people:self", + selfService: true, + }, + { id: "security", title: "Sign-in & security", icon: KeyRound, path: "/dashboard/account", selfService: true }, + ], +} + +const people: Service = { + id: "people", + title: "People", + short: "People", + icon: UsersRound, + path: "/dashboard/people", + sections: [ + { + id: "overview", + title: "Overview", + icon: LayoutList, + path: "/dashboard/people", + permission: "people:read", + searchPlaceholder: "Search by name, email or Telegram…", + }, + { + id: "members", + title: "Member register", + icon: IdCard, + path: "/dashboard/people/members", + permission: "people:members:read", + searchPlaceholder: "Search by name, number or email…", + }, + { + id: "admins", + title: "Admins", + icon: UserCog, + path: "/dashboard/people/admins", + permission: "people:read", + searchPlaceholder: "Search by name, course or group…", + }, + { + id: "teams", + title: "Teams", + icon: Users, + path: "/dashboard/people/teams", + permission: "people:read", + role: "lead", + searchPlaceholder: "Search team members…", + }, + { + id: "census", + title: "Census", + icon: ClipboardList, + path: "/dashboard/people/census", + permission: "people:read", + }, + { + id: "linking", + title: "Linking", + icon: Link2, + path: "/dashboard/people/linking", + permission: "people:read", + searchPlaceholder: "Search by name or email…", + }, + ], } const telegram: Service = { @@ -133,6 +234,7 @@ const telegram: Service = { permission: "tg:grants:read", searchPlaceholder: "Search users, authorizers or reasons…", }, + { id: "capo", title: "Capo Admin", icon: Crown, path: "/dashboard/telegram/capo", role: "capo" }, ], } @@ -254,12 +356,42 @@ const reports: Service = { ], } -/** Rail order: Overview (the logo), then the five services with sections, then Account. */ -export const services: Service[] = [overview, telegram, whatsapp, m365, web, reports, account] +/** Rail order: Overview (the logo), then the services with sections, then Account (the avatar). */ +export const services: Service[] = [overview, people, telegram, whatsapp, m365, web, reports, account] /** The services that own a panel, in rail order. */ export const panelServices: Service[] = services.filter((service) => service.sections.length > 0) +/** Rail, bottom-nav and palette services: Account has sections but lives under the avatar. */ +export function isRailService(service: Service) { + return service.id !== "account" && service.sections.length > 0 +} + +/** + * Whether the user sees a section: a record role opens it on its own; otherwise its permissions, plus `admin:access` + * for every section that isn't self-service. + */ +export function sectionVisible(section: Section, permissions: readonly string[], roles: PeopleRoles) { + if (section.role === "capo" && roles.capoScope !== null) return true + if (section.role === "lead" && roles.leads.length > 0) return true + if (section.role && !section.permission && !section.anyPermission) return false + if (!section.selfService && !hasPermission(permissions, "admin:access")) return false + return ( + (!section.permission || hasPermission(permissions, section.permission)) && + (!section.anyPermission || section.anyPermission.some((permission) => hasPermission(permissions, permission))) + ) +} + +/** Whether the user may open this page; the `/dashboard` route sends everything else to My area or Sign-in. */ +export function canOpenPage(match: PageMatch, permissions: readonly string[], roles: PeopleRoles) { + if (match.kind === "not-found") return true + const current = sectionFor(match) + if (!current) return hasPermission(permissions, "admin:access") + // Team pages: a Team Lead without people:read only reaches their own team, which People › Teams opens directly. + if (match.kind === "people-team" && !hasPermission(permissions, "people:read")) return false + return sectionVisible(current, permissions, roles) +} + export function serviceById(id: ServiceId): Service { return services.find((service) => service.id === id) ?? overview } @@ -279,6 +411,7 @@ export function matchPath(pathname: string): PageMatch { if (head === undefined) return { kind: "overview" } if (head === "account" && second === undefined) return { kind: "account" } + if (head === "account" && second === "me" && third === undefined && rest.length === 0) return { kind: "account-me" } // Category pages take the whole label path as their splat. if (head === "web" && second === "groups-by-label" && third !== undefined) { @@ -286,7 +419,22 @@ export function matchPath(pathname: string): PageMatch { } if (rest.length > 0) return { kind: "not-found" } + if (head === "people") { + if (second === undefined) return { kind: "people-overview" } + if (third === undefined) { + if (second === "members") return { kind: "people-members" } + if (second === "admins") return { kind: "people-admins" } + if (second === "teams") return { kind: "people-teams" } + if (second === "census") return { kind: "people-census" } + if (second === "linking") return { kind: "people-linking" } + if (/^P-\d{4,}$/.test(second)) return { kind: "people-person", personId: second } + } + if (second === "teams" && third !== undefined) return { kind: "people-team", teamId: third } + if (second === "census" && third !== undefined) return { kind: "people-campaign", campaignId: third } + } + if (head === "telegram") { + if (second === "capo" && third === undefined) return { kind: "telegram-capo" } if (second === "users" && third === undefined) return { kind: "telegram-users" } if (second === "users" && third !== undefined && /^\d+$/.test(third)) { return { kind: "telegram-user", userId: Number(third) } @@ -328,7 +476,19 @@ export function serviceFor(match: PageMatch): Service { case "not-found": return overview case "account": + case "account-me": return account + case "people-overview": + case "people-person": + case "people-members": + case "people-admins": + case "people-teams": + case "people-team": + case "people-census": + case "people-campaign": + case "people-linking": + return people + case "telegram-capo": case "telegram-users": case "telegram-user": case "telegram-groups": @@ -358,9 +518,29 @@ export function serviceFor(match: PageMatch): Service { export function sectionFor(match: PageMatch): Section | null { switch (match.kind) { case "overview": - case "account": case "not-found": return null + case "account": + return section(account, "security") + case "account-me": + return section(account, "me") + case "people-overview": + case "people-person": + return section(people, "overview") + case "people-members": + return section(people, "members") + case "people-admins": + return section(people, "admins") + case "people-teams": + case "people-team": + return section(people, "teams") + case "people-census": + case "people-campaign": + return section(people, "census") + case "people-linking": + return section(people, "linking") + case "telegram-capo": + return section(telegram, "capo") case "telegram-users": case "telegram-user": return section(telegram, "users") @@ -397,7 +577,14 @@ export function sectionFor(match: PageMatch): Section | null { /** Deep pages are reached from a section but are not themselves in the panel. */ export function isDeepPage(match: PageMatch): boolean { - return match.kind === "telegram-user" || match.kind === "web-category" || match.kind === "web-tag" + return ( + match.kind === "telegram-user" || + match.kind === "web-category" || + match.kind === "web-tag" || + match.kind === "people-person" || + match.kind === "people-team" || + match.kind === "people-campaign" + ) } /** `document.title`: "{Section} · {Service} · PoliNetwork Admin", or "{Service} · PoliNetwork Admin". */ diff --git a/src/components/shell/rail.tsx b/src/components/shell/rail.tsx index bbba587e..d371b46a 100644 --- a/src/components/shell/rail.tsx +++ b/src/components/shell/rail.tsx @@ -19,10 +19,10 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip import { cn } from "@/lib/utils" import { AccountAvatar, type ShellUser } from "./account-avatar" -import { type DashboardPath, type PageMatch, type Service, serviceById, serviceFor } from "./nav" +import { type DashboardPath, isRailService, type PageMatch, type Service, serviceById, serviceFor } from "./nav" import { ServiceGlyph } from "./service-glyph" import { themeToggleLabel, useTheme } from "./theme" -import { useVisibleServices } from "./use-can" +import { useHasAdminAccess, useVisibleServices } from "./use-can" const railItemClass = "relative grid size-10 shrink-0 place-items-center rounded-(--pn-r-3) text-(--pn-fg-muted) transition-[background-color,color] duration-120 hover:bg-(--pn-hover) hover:text-(--pn-fg)" @@ -129,7 +129,8 @@ export function OpenReportsDot({ /** The 64px service rail. One tab stop; ↑/↓, Home and End move between items. */ export function Rail({ match, serviceHref, onOpenPalette, user, pendingReports, className }: RailProps) { - const panelServices = useVisibleServices().filter((service) => service.sections.length > 0) + const panelServices = useVisibleServices().filter(isRailService) + const hasAdminAccess = useHasAdminAccess() const navRef = useRef(null) const [focusedIndex, setFocusedIndex] = useState(null) const { theme, toggleTheme } = useTheme() @@ -199,10 +200,10 @@ export function Rail({ match, serviceHref, onOpenPalette, user, pendingReports, > {/* The logo uses the same active fill and left indicator as every other rail item. */} @@ -260,7 +261,7 @@ export function Rail({ match, serviceHref, onOpenPalette, user, pendingReports, tooltip={user.name || user.email} active={activeId === "account"} focusable={tabStop === accountIndex} - target={{ kind: "link", to: account.path }} + target={{ kind: "link", to: serviceHref(account) }} > diff --git a/src/components/shell/use-can.ts b/src/components/shell/use-can.ts index eb7d7a18..a182b1a3 100644 --- a/src/components/shell/use-can.ts +++ b/src/components/shell/use-can.ts @@ -2,7 +2,7 @@ import { getRouteApi } from "@tanstack/react-router" import { hasPermission, type Permission } from "@/server/permissions" -import { services } from "./nav" +import { NO_PEOPLE_ROLES, sectionVisible, services } from "./nav" const dashboardRoute = getRouteApi("/dashboard") @@ -15,16 +15,26 @@ export function useCan(permission: Permission) { return hasPermission(permissions, permission) } -/** The sections available to the user, including read-specific and dedicated member permissions. */ +/** Whether the user has the full dashboard (`admin:access`), not only the restricted People view. */ +export function useHasAdminAccess() { + return dashboardRoute.useRouteContext({ select: (context) => hasPermission(context.permissions, "admin:access") }) +} + +/** The Capo Admin scope and led teams the signed-in person's record gives them. */ +export function usePeopleRoles() { + return dashboardRoute.useRouteContext({ select: (context) => context.peopleRoles ?? NO_PEOPLE_ROLES }) +} + +/** + * The sections available to the user: permissions, `admin:access` for everything but Account, and the roles from + * their People record (Capo Admin, Team Lead). + */ export function useVisibleServices() { const permissions = dashboardRoute.useRouteContext({ select: (context) => context.permissions }) + const roles = usePeopleRoles() return services.flatMap((service) => { - if (service.sections.length === 0) return [service] - const sections = service.sections.filter( - (section) => - (!section.permission || hasPermission(permissions, section.permission)) && - (!section.anyPermission || section.anyPermission.some((permission) => hasPermission(permissions, permission))) - ) + if (service.sections.length === 0) return hasPermission(permissions, "admin:access") ? [service] : [] + const sections = service.sections.filter((section) => sectionVisible(section, permissions, roles)) return sections.length ? [{ ...service, sections, path: sections[0].path }] : [] }) } diff --git a/src/features/dashboard/overview-page.tsx b/src/features/dashboard/overview-page.tsx index 30c762ce..841ed1dc 100644 --- a/src/features/dashboard/overview-page.tsx +++ b/src/features/dashboard/overview-page.tsx @@ -3,7 +3,15 @@ import { ArrowRight, ArrowUp, ChevronRight, Search } from "lucide-react" import { type ReactNode, Suspense, use, useState } from "react" import { InlineAlert, LoadingButton, SegmentedControl, useModifierKey } from "@/components/primitives" -import { type DashboardPath, PageBar, PageContent, type Service, ServiceGlyph, useShellFrame } from "@/components/shell" +import { + type DashboardPath, + isRailService, + PageBar, + PageContent, + type Service, + ServiceGlyph, + useShellFrame, +} from "@/components/shell" import { useVisibleServices } from "@/components/shell/use-can" import { formatNumber } from "@/lib/format" import { cn } from "@/lib/utils" @@ -260,7 +268,7 @@ function chartDescription(points: ReturnType["points"], ran /** Every service the user can open: the whole card leads where its rail icon does, each section is a 44px button. */ function Areas({ openReports }: { openReports: OverviewCount }) { - const services = useVisibleServices().filter((service) => service.sections.length > 0) + const services = useVisibleServices().filter(isRailService) return (

diff --git a/src/features/people/page-placeholder.tsx b/src/features/people/page-placeholder.tsx new file mode 100644 index 00000000..381563af --- /dev/null +++ b/src/features/people/page-placeholder.tsx @@ -0,0 +1,20 @@ +import { Construction } from "lucide-react" + +import { EmptyState } from "@/components/primitives" +import { PageBar, PageContent } from "@/components/shell" + +/** Stand-in while the People pages are built (docs/people/plan.md); removed once every page exists. */ +export function PagePlaceholder() { + return ( + <> + + + + + + ) +} diff --git a/src/features/people/people.api.server.ts b/src/features/people/people.api.server.ts new file mode 100644 index 00000000..47a6ec79 --- /dev/null +++ b/src/features/people/people.api.server.ts @@ -0,0 +1,184 @@ +import type { z } from "zod" + +import { createUntypedBackendClient } from "@/server/backend.server" + +import { + type AddTeamMemberInput, + type AnswerCensusInput, + type AnswersInput, + type AskToBeAddedInput, + accountMatchSchema, + type AccountMatch, + type Campaign, + campaignDetailSchema, + type CampaignDetail, + campaignSchema, + type CapoArea, + capoAreaSchema, + type CapoScopeInput, + censusAnswersSchema, + type CorrectionInput, + type CreateMemberInput, + type CreatePersonInput, + createdSchema, + type EndAdminInput, + type EndTeamInput, + exportResultSchema, + type ImportCommitInput, + importedSchema, + type ImportPreview, + importPreviewSchema, + type Invite, + inviteSchema, + type LinkAccountInput, + type MemberNumberInput, + type MemberStatusInput, + type MyArea, + myAreaSchema, + type MyRoles, + myRolesSchema, + type PersonRecord, + personRecordSchema, + type PersonRow, + personRowSchema, + type RemindInput, + remindResultSchema, + type ResolveReviewInput, + type Review, + reviewSchema, + type SearchAccountsInput, + type SetNoteInput, + type TeamFieldsInput, + type TeamSummary, + teamSummarySchema, + type UpdatePersonInput, +} from "./people.contract" + +/** Who is asking: their permissions, and their own record in agent mode (`AGENT_PERSON`). */ +export type PeopleViewer = { permissions: readonly string[]; personId: string | null } + +/** The `people.*` procedures (docs/people/contract.md), one method each. */ +export type PeopleApi = { + listPeople: () => Promise + getPerson: (id: string) => Promise + createPerson: (input: CreatePersonInput) => Promise<{ id: string }> + updatePerson: (input: UpdatePersonInput) => Promise + setNote: (input: SetNoteInput) => Promise + createMember: (input: CreateMemberInput) => Promise + setMemberStatus: (input: MemberStatusInput) => Promise + setMemberNumber: (input: MemberNumberInput) => Promise + exportMembers: () => Promise<{ filename: string; csv: string }> + endAdmin: (input: EndAdminInput) => Promise + setCapoScope: (input: CapoScopeInput) => Promise + listTeams: () => Promise + addTeamMember: (input: AddTeamMemberInput) => Promise + updateTeamFields: (input: TeamFieldsInput) => Promise + endTeamMembership: (input: EndTeamInput) => Promise + listCampaigns: () => Promise + getCampaign: (id: string) => Promise + getAnswers: (input: AnswersInput) => Promise<{ question: string; answer: string }[]> + remind: (input: RemindInput) => Promise<{ sent: number; skipped: number }> + closeCampaign: (id: string) => Promise + importPreview: (campaignId: string, file: File) => Promise + importCommit: (input: ImportCommitInput) => Promise<{ imported: number }> + listReviews: () => Promise + resolveReview: (input: ResolveReviewInput) => Promise + dismissReview: (input: { reviewId: string }) => Promise + listInvites: () => Promise + sendInvite: (input: { personId: string }) => Promise + revokeInvite: (input: { personId: string }) => Promise + searchAccounts: (input: SearchAccountsInput) => Promise + linkAccount: (input: LinkAccountInput) => Promise + unlinkAccount: (input: { personId: string }) => Promise + getCapoArea: () => Promise + approveJoinRequest: (input: { requestId: string }) => Promise + declineJoinRequest: (input: { requestId: string }) => Promise + getMyRoles: () => Promise + getMyArea: () => Promise + confirmMatch: () => Promise + rejectMatch: () => Promise + askToBeAdded: (input: AskToBeAddedInput) => Promise + answerCensus: (input: AnswerCensusInput) => Promise + requestCorrection: (input: CorrectionInput) => Promise + revokePhotoConsent: () => Promise +} + +/** + * The backend's `people.*` procedures. The published router type doesn't declare them yet, so every response is + * parsed with the contract's schema: a backend that drifts from the contract fails loudly here, not in a page. + */ +function backendPeopleApi(accessToken: string | null): PeopleApi { + const client = createUntypedBackendClient(accessToken) + const query = async (path: string, input: I, schema: z.ZodType) => + schema.parse(await client.query(`people.${path}`, input)) + const mutate = async (path: string, input?: I) => { + await client.mutation(`people.${path}`, input) + } + const mutateParsed = async (path: string, input: I, schema: z.ZodType) => + schema.parse(await client.mutation(`people.${path}`, input)) + + return { + listPeople: () => query("persons.list", undefined, personRowSchema.array()), + getPerson: (id) => query("persons.get", { id }, personRecordSchema), + createPerson: (input) => mutateParsed("persons.create", input, createdSchema), + updatePerson: (input) => mutate("persons.update", input), + setNote: (input) => mutate("persons.setNote", input), + createMember: (input) => mutate("members.create", input), + setMemberStatus: (input) => mutate("members.setStatus", input), + setMemberNumber: (input) => mutate("members.setNumber", input), + exportMembers: () => mutateParsed("members.export", undefined, exportResultSchema), + endAdmin: (input) => mutate("admins.end", input), + setCapoScope: (input) => mutate("admins.setCapoScope", input), + listTeams: () => query("teams.list", undefined, teamSummarySchema.array()), + addTeamMember: (input) => mutate("teams.addMember", input), + updateTeamFields: (input) => mutate("teams.updateFields", input), + endTeamMembership: (input) => mutate("teams.endMembership", input), + listCampaigns: () => query("census.campaigns", undefined, campaignSchema.array()), + getCampaign: (id) => query("census.campaign", { id }, campaignDetailSchema), + getAnswers: (input) => query("census.answers", input, censusAnswersSchema), + remind: (input) => mutateParsed("census.remind", input, remindResultSchema), + closeCampaign: (id) => mutate("census.close", { campaignId: id }), + importPreview: (campaignId, file) => { + const form = new FormData() + form.set("campaignId", campaignId) + form.set("file", file) + return mutateParsed("census.importPreview", form, importPreviewSchema) + }, + importCommit: (input) => mutateParsed("census.importCommit", input, importedSchema), + listReviews: () => query("linking.reviews", undefined, reviewSchema.array()), + resolveReview: (input) => mutate("linking.resolve", input), + dismissReview: (input) => mutate("linking.dismiss", input), + listInvites: () => query("linking.invites", undefined, inviteSchema.array()), + sendInvite: (input) => mutate("linking.sendInvite", input), + revokeInvite: (input) => mutate("linking.revokeInvite", input), + searchAccounts: (input) => query("linking.searchAccounts", input, accountMatchSchema.array()), + linkAccount: (input) => mutate("linking.link", input), + unlinkAccount: (input) => mutate("linking.unlink", input), + getCapoArea: () => query("capo.get", undefined, capoAreaSchema), + approveJoinRequest: (input) => mutate("capo.approve", input), + declineJoinRequest: (input) => mutate("capo.decline", input), + getMyRoles: () => query("me.roles", undefined, myRolesSchema), + getMyArea: () => query("me.get", undefined, myAreaSchema), + confirmMatch: () => mutate("me.confirmMatch"), + rejectMatch: () => mutate("me.rejectMatch"), + askToBeAdded: (input) => mutate("me.askToBeAdded", input), + answerCensus: (input) => mutate("me.answerCensus", input), + requestCorrection: (input) => mutate("me.requestCorrection", input), + revokePhotoConsent: () => mutate("me.revokePhotoConsent"), + } +} + +type PeopleContext = { + agentMode: boolean + session: { accessToken: string | null } + permissions: readonly string[] +} + +/** The People API for this request: the sample data in agent mode, the backend otherwise. */ +export async function peopleApi(context: PeopleContext): Promise { + if (context.agentMode) { + const [{ fakePeopleApi }, { env }] = await Promise.all([import("./people.fake.server"), import("@/env")]) + return fakePeopleApi({ permissions: context.permissions, personId: env.AGENT_PERSON }) + } + return backendPeopleApi(context.session.accessToken) +} diff --git a/src/features/people/people.contract.ts b/src/features/people/people.contract.ts index 6a10b650..ab570ad3 100644 --- a/src/features/people/people.contract.ts +++ b/src/features/people/people.contract.ts @@ -376,3 +376,135 @@ export const TEAMS = { ], }, } satisfies Record + +// Inputs. The server functions validate with these, and the fake and real APIs take the parsed values. + +const personIdSchema = z.string().regex(/^P-\d{4}$/) +const dateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/) +const reasonSchema = z.string().trim().min(1).max(500) + +export const personInputSchema = z.object({ + firstName: z.string().trim().min(1).max(80), + lastName: z.string().trim().min(1).max(80), + personalEmail: z.email(), + phone: z.string().trim().max(40), + telegramUsername: z + .string() + .trim() + .max(40) + .transform((value) => value.replace(/^@/, "")), + course: z.string().trim().min(1).max(120), + year: z.enum(YEARS), + campus: campusSchema, +}) +export const createPersonInputSchema = personInputSchema.extend({ admin: z.boolean() }) +export const updatePersonInputSchema = personInputSchema.extend({ id: personIdSchema }) +export const setNoteInputSchema = z.object({ id: personIdSchema, note: z.string().trim().max(2000) }) + +export const createMemberInputSchema = z.object({ + personId: personIdSchema, + number: z.string().regex(/^\d{4}$/), + admittedOn: dateSchema, + fee: z.enum(["due", "receipt", "paid"]), +}) +export const memberStatusInputSchema = z.object({ + personId: personIdSchema, + status: memberStatusSchema, + reason: z.string().trim().max(500), +}) +export const memberNumberInputSchema = z.object({ personId: personIdSchema, number: z.string().regex(/^\d{4}$/) }) + +export const endAdminInputSchema = z.object({ personId: personIdSchema, reason: reasonSchema }) +export const capoScopeInputSchema = z.object({ personId: personIdSchema, scope: z.string().trim().max(120).nullable() }) + +export const addTeamMemberInputSchema = z.object({ + teamId: teamIdSchema, + personId: personIdSchema, + role: z.enum(["member", "lead"]), + since: dateSchema, + fields: teamFieldsSchema, +}) +export const teamFieldsInputSchema = z.object({ + teamId: teamIdSchema, + personId: personIdSchema, + fields: teamFieldsSchema, +}) +export const endTeamInputSchema = z.object({ + teamId: teamIdSchema, + personId: personIdSchema, + lastDay: dateSchema, + reason: reasonSchema, + note: z.string().trim().max(500), +}) + +export const campaignInputSchema = z.object({ campaignId: z.string().min(1) }) +export const remindInputSchema = z.object({ campaignId: z.string().min(1), personId: personIdSchema.nullable() }) +export const answersInputSchema = z.object({ personId: personIdSchema, year: z.string().min(1) }) +export const importCommitInputSchema = z.object({ + importId: z.string().min(1), + duplicates: z.record(z.string(), z.enum(["same", "different"])), + telegram: z.record(z.string(), z.string().nullable()), +}) + +export const resolveReviewInputSchema = z.object({ + reviewId: z.string().min(1), + resolution: z.string().min(1), + reason: reasonSchema, +}) +export const reviewInputSchema = z.object({ reviewId: z.string().min(1) }) +export const personRefInputSchema = z.object({ personId: personIdSchema }) +export const searchAccountsInputSchema = z.object({ personId: personIdSchema, query: z.string().trim().max(120) }) +export const linkAccountInputSchema = z.object({ + personId: personIdSchema, + accountId: z.string().min(1), + reason: reasonSchema, +}) + +export const joinRequestInputSchema = z.object({ requestId: z.string().min(1) }) + +export const askToBeAddedInputSchema = z.object({ + roles: z.array(z.enum(["admin", "team", "member"])).min(1), + groupsOrTeam: z.string().trim().max(200), + telegramUsername: z.string().trim().max(40), +}) +export const answerCensusInputSchema = z.object({ + answer: censusAnswerSchema, + course: z.string().trim().min(1).max(120), + year: z.enum(YEARS), + campus: campusSchema, + keepGroups: z.array(z.string()), + keepTeams: z.array(teamIdSchema), + reason: z.string().trim().max(200), + note: z.string().trim().max(500), +}) +export const correctionInputSchema = z.object({ + field: z.string().min(1), + value: z.string().trim().min(1).max(200), + note: z.string().trim().max(500), +}) + +export type CreatePersonInput = z.infer +export type UpdatePersonInput = z.infer +export type SetNoteInput = z.infer +export type CreateMemberInput = z.infer +export type MemberStatusInput = z.infer +export type MemberNumberInput = z.infer +export type EndAdminInput = z.infer +export type CapoScopeInput = z.infer +export type AddTeamMemberInput = z.infer +export type TeamFieldsInput = z.infer +export type EndTeamInput = z.infer +export type RemindInput = z.infer +export type AnswersInput = z.infer +export type ImportCommitInput = z.infer +export type ResolveReviewInput = z.infer +export type SearchAccountsInput = z.infer +export type LinkAccountInput = z.infer +export type AskToBeAddedInput = z.infer +export type AnswerCensusInput = z.infer +export type CorrectionInput = z.infer + +export const remindResultSchema = z.object({ sent: z.number().int(), skipped: z.number().int() }) +export const exportResultSchema = z.object({ filename: z.string(), csv: z.string() }) +export const createdSchema = z.object({ id: z.string() }) +export const importedSchema = z.object({ imported: z.number().int() }) diff --git a/src/features/people/people.fake.server.ts b/src/features/people/people.fake.server.ts new file mode 100644 index 00000000..f7c24d74 --- /dev/null +++ b/src/features/people/people.fake.server.ts @@ -0,0 +1,953 @@ +import type { PeopleApi, PeopleViewer } from "./people.api.server" +import { + type AccountMatch, + type Campaign, + type CampaignDetail, + type CapoArea, + type Discrepancy, + type ImportPreview, + type MyArea, + type PersonRecord, + type PersonRow, + TEAM_IDS, + TEAMS, + type TeamId, + type TeamSummary, +} from "./people.contract" +import { + addDays, + buildPeopleStore, + FAMILIES, + FAMILY_IDS, + GROUP_IDS, + m365Address, + type PeopleStore, + type StoredPerson, +} from "./people.fixtures.server" + +/** + * Agent mode's stand-in for the backend's `people.*` procedures, over `people.fixtures.server.ts`. State lives for the + * dev server's lifetime, so mutations show up on reload. It enforces the same visibility rules the backend must: + * contacts and notes only with `people:read`, a Capo Admin's scope, or a Team Lead's team. + */ + +let store: PeopleStore | null = null +/** The agent user's own state when `AGENT_PERSON` is `none` or `match:P-…`. */ +type SelfState = { matchConfirmed: boolean; matchRejected: boolean; askedOn: string | null } +const self: SelfState = { matchConfirmed: false, matchRejected: false, askedOn: null } +const importedFiles = new Set() +const previews = new Map() + +function data() { + store ??= buildPeopleStore() + return store +} + +function today() { + return new Date().toISOString().slice(0, 10) +} + +function nowStamp() { + return new Date().toISOString() +} + +function fullName(person: StoredPerson) { + return `${person.profile.firstName} ${person.profile.lastName}` +} + +function find(id: string) { + const person = data().byId.get(id) + if (!person) throw new Error("This person no longer exists.") + return person +} + +function log(person: StoredPerson, actor: string, text: string) { + person.activity.unshift({ actor, text, at: nowStamp() }) +} + +function discrepancies(person: StoredPerson): Discrepancy[] { + const out: Discrepancy[] = [] + const current = person.census.find((entry) => entry.year === "2026/27") + const isMember = person.member !== null && person.member.status !== "former" + if (current?.declaredMember && !isMember) { + out.push({ + kind: "declared-member", + message: "Declared a member in the 2026/27 census, but not in the member register.", + suggestedTelegram: null, + }) + } + if (person.censusTelegram && person.censusTelegram !== person.profile.telegramUsername) { + out.push({ + kind: "telegram-mismatch", + message: `The 2026/27 census says @${person.censusTelegram}, but the record says @${person.profile.telegramUsername ?? "—"}.`, + suggestedTelegram: person.censusTelegram, + }) + } + if (person.account.status === "review") { + out.push({ + kind: "needs-review", + message: "A sign-in matches this record but needs review before it can be linked.", + suggestedTelegram: null, + }) + } + return out +} + +/** The contract row, with contacts kept only when `withContacts`. */ +function row(person: StoredPerson, withContacts: boolean): PersonRow { + return { + id: person.id, + profile: { + ...person.profile, + personalEmail: withContacts ? person.profile.personalEmail : null, + phone: withContacts ? person.profile.phone : null, + }, + member: person.member, + admin: person.admin, + teams: person.teams, + census: person.census, + consents: person.consents, + account: person.account, + } +} + +function record(person: StoredPerson, viewer: PeopleViewer): PersonRecord { + const canRead = viewer.permissions.includes("people:read") + return { + ...row(person, canRead || viewer.personId === person.id), + notes: canRead ? person.notes : null, + discrepancies: discrepancies(person), + activity: [...person.activity].sort((a, b) => (a.at < b.at ? 1 : -1)), + } +} + +function require(viewer: PeopleViewer, permission: string) { + if (!viewer.permissions.includes(permission)) throw new Error("UNAUTHORIZED") +} + +function viewerName(viewer: PeopleViewer) { + const person = viewer.personId ? data().byId.get(viewer.personId) : undefined + return person ? fullName(person) : "Agent" +} + +/** The person the agent user is in My area: `AGENT_PERSON` without its `match:` prefix. */ +function viewerRecord(viewer: PeopleViewer) { + if (!viewer.personId || viewer.personId === "none") return null + const id = viewer.personId.replace(/^match:/, "") + return data().byId.get(id) ?? null +} + +function leadsOf(person: StoredPerson | null): TeamId[] { + if (!person) return [] + return person.teams.filter((team) => team.role === "lead" && !team.endedOn).map((team) => team.team) +} + +function campaigns(): Campaign[] { + const people = data().people + const count = (year: string) => { + const entries = people.flatMap((person) => person.census.filter((entry) => entry.year === year)) + const expected = year === "2026/27" ? people.filter((person) => person.expected).length : entries.length + const continuing = entries.filter((entry) => entry.answer === "continues").length + const leaving = entries.filter((entry) => entry.answer === "leaving").length + return { expected, continuing, leaving, noAnswer: expected - continuing - leaving } + } + return [ + { + id: "2026", + year: "2026/27", + status: "open", + openedOn: "2026-09-15", + closesOn: "2026-10-31", + source: "Modulo Volontari 2026/27", + ...count("2026/27"), + }, + { + id: "2025", + year: "2025/26", + status: "closed", + openedOn: "2025-09-15", + closesOn: "2025-10-31", + source: "Modulo Volontari 2025/26", + ...count("2025/26"), + }, + ] +} + +function familyOfScope(scope: string) { + return FAMILY_IDS.find((family) => FAMILIES[family].scope === scope) +} + +function buildPreview(fileName: string): ImportPreview { + const people = data().people.filter((person) => person.census.some((entry) => entry.source === "forms")) + const reimport = importedFiles.has(fileName) + const withErrors = fileName.toLowerCase().endsWith(".csv") + const answer = (person: StoredPerson) => + person.census.find((entry) => entry.year === "2026/27")?.answer ?? "continues" + const newNames = [ + "Leonardo Sala", + "Greta Bellini", + "Samuele Riva", + "Ludovica Neri", + "Emanuele Vitali", + "Arianna Donati", + "Jacopo Piras", + "Bianca Sartori", + "Diego Milani", + ] + const counts = reimport + ? { newPeople: 4, updates: 8, duplicates: 0, invalid: 0, discrepancies: 1, alreadyImported: 148, leaving: 1 } + : { + newPeople: 9, + updates: 136, + duplicates: 3, + invalid: withErrors ? 4 : 0, + discrepancies: 6, + alreadyImported: 0, + leaving: 7, + } + const giulia = find("P-0142") + const duplicatePeople = people.slice(10, 10 + counts.duplicates) + return { + importId: `import-${Date.now()}`, + fileName, + rows: reimport ? 160 : withErrors ? 152 : 148, + counts, + tabs: { + newPeople: newNames.slice(0, counts.newPeople).map((name, index) => ({ + row: 140 + index, + name, + detail: [ + "Ingegneria Gestionale · 1st year · Bovisa", + "Design della Comunicazione · 1st year · Bovisa", + "Ingegneria Informatica · 1st year · Leonardo", + ][index % 3], + answer: "continues", + })), + updates: people.slice(0, Math.min(8, counts.updates)).map((person, index) => ({ + row: 3 + index * 7, + name: fullName(person), + detail: [ + `Year ${person.profile.year} → next year`, + "Campus Leonardo → Bovisa", + "Phone updated", + "Telegram username updated", + ][index % 4], + answer: answer(person), + })), + duplicates: duplicatePeople.map((person, index) => ({ + row: 44 + index * 31, + name: fullName(person), + detail: + index === 1 + ? `Same name as ${person.id}; different course and Telegram` + : `Same name as ${person.id}; same Telegram, different email`, + answer: "continues", + })), + invalid: [ + { row: 23, name: "Pietro Gallo", detail: "Personal email is missing", answer: null }, + { row: 57, name: "Unnamed row", detail: "Name is empty", answer: null }, + { row: 88, name: "Sofia Marchetti", detail: "Year “4th” isn't a valid year", answer: null }, + { row: 131, name: "Luca Moretti", detail: "Telegram username “luca moretti” has a space", answer: null }, + ].slice(0, counts.invalid), + discrepancies: [ + { + row: 12, + name: fullName(giulia), + detail: "Declared a member, but not in the member register", + answer: "continues" as const, + }, + ...people.slice(20, 20 + counts.discrepancies - 1).map((person, index) => ({ + row: 30 + index * 9, + name: fullName(person), + detail: [ + "Telegram username differs from the record", + "Says they administer Statistica; the bot hasn't seen them there", + "Campus Cremona in the form, Leonardo on the record", + "Answered “No” but administers 2 groups", + ][index % 4], + answer: answer(person), + })), + ].slice(0, counts.discrepancies), + alreadyImported: reimport + ? people.slice(0, 8).map((person, index) => ({ + row: 1 + index, + name: fullName(person), + detail: "Already imported, skipped", + answer: answer(person), + })) + : [], + }, + duplicates: duplicatePeople.map((person, index) => { + const fileEmail = + index === 1 + ? `${person.profile.firstName.toLowerCase()}.${person.profile.lastName.toLowerCase()}@mail.polimi.it` + : (person.profile.personalEmail ?? "").replace("@", "1@") + const telegram = `@${person.profile.telegramUsername ?? "—"}` + return { + pairId: `pair-${index}`, + row: 44 + index * 31, + recordId: person.id, + fields: [ + { label: "Name", file: fullName(person), record: fullName(person), mono: false }, + { label: "Email", file: fileEmail, record: person.profile.personalEmail ?? "—", mono: true }, + { + label: "Course", + file: index === 1 ? "Ingegneria Civile" : person.profile.course, + record: person.profile.course, + mono: false, + }, + { label: "Telegram", file: index === 1 ? `${telegram}99` : telegram, record: telegram, mono: true }, + { label: "Year", file: person.profile.year, record: person.profile.year, mono: false }, + ], + } + }), + telegram: people.slice(30, 36).map((person, index) => ({ + rowId: `row-${person.id}`, + name: fullName(person), + username: `@${person.profile.telegramUsername ?? `${person.profile.firstName.toLowerCase()}`}`, + suggestion: + index === 4 + ? null + : { + telegramId: person.profile.telegramId ?? "", + name: fullName(person), + info: person.admin ? `Admin in ${Math.max(1, person.admin.groups.length)} groups` : "Member of 3 groups", + }, + })), + } +} + +export function fakePeopleApi(viewer: PeopleViewer): PeopleApi { + const actor = viewerName(viewer) + return { + listPeople: async () => { + require(viewer, "people:read") + return data().people.map((person) => row(person, true)) + }, + getPerson: async (id) => { + require(viewer, "people:read") + return record(find(id), viewer) + }, + createPerson: async (input) => { + require(viewer, "people:write") + const { people, byId } = data() + if (people.some((person) => person.profile.personalEmail === input.personalEmail)) { + throw new Error(`${input.personalEmail} already belongs to another record.`) + } + const id = `P-${String(people.length + 1).padStart(4, "0")}` + const person: StoredPerson = { + id, + family: "inf", + profile: { + firstName: input.firstName, + lastName: input.lastName, + personalEmail: input.personalEmail, + phone: input.phone || null, + telegramUsername: input.telegramUsername || null, + telegramId: null, + course: input.course, + year: input.year, + campus: input.campus, + otherAssociations: [], + studentRepresentative: null, + }, + member: null, + admin: input.admin + ? { + status: "active", + since: today(), + endedOn: null, + endReason: null, + groups: [], + requested: [], + availableAsCapo: false, + capoOfReference: null, + capoScope: null, + } + : null, + teams: [], + census: [], + consents: { privacy: null, photo: null }, + account: { status: "none" }, + notes: null, + activity: [], + start: input.admin ? today() : null, + censusTelegram: null, + expected: false, + } + log(person, actor, "created the record") + people.push(person) + byId.set(id, person) + return { id } + }, + updatePerson: async (input) => { + require(viewer, "people:write") + const person = find(input.id) + Object.assign(person.profile, { + firstName: input.firstName, + lastName: input.lastName, + personalEmail: input.personalEmail, + phone: input.phone || null, + telegramUsername: input.telegramUsername || null, + course: input.course, + year: input.year, + campus: input.campus, + }) + log(person, actor, "edited the profile") + }, + setNote: async (input) => { + require(viewer, "people:write") + const person = find(input.id) + person.notes = input.note || null + log(person, actor, "edited the HR notes") + }, + createMember: async (input) => { + require(viewer, "people:members:write") + const person = find(input.personId) + if (data().people.some((other) => other.member?.number === input.number)) { + throw new Error(`Member number ${input.number} is already taken.`) + } + person.member = { + number: input.number, + status: "active", + admittedOn: input.admittedOn, + fee: input.fee, + m365Email: m365Address(person.profile.firstName, person.profile.lastName), + } + log(person, actor, `admitted ${person.profile.firstName} as member ${input.number}`) + }, + setMemberStatus: async (input) => { + require(viewer, "people:members:write") + const person = find(input.personId) + if (!person.member) throw new Error("This person isn't a member.") + person.member.status = input.status + if (input.status === "former") person.member.fee = null + log(person, actor, `changed the member status to ${input.status}${input.reason ? `: ${input.reason}` : ""}`) + }, + setMemberNumber: async (input) => { + require(viewer, "people:members:write") + const person = find(input.personId) + if (!person.member) throw new Error("This person isn't a member.") + const taken = data().people.find((other) => other !== person && other.member?.number === input.number) + if (taken) throw new Error(`Member number ${input.number} belongs to ${fullName(taken)}.`) + const previous = person.member.number + person.member.number = input.number + log(person, actor, `changed the member number from ${previous} to ${input.number}`) + }, + exportMembers: async () => { + require(viewer, "people:members:write") + const lines = [["Number", "Name", "Status", "Admitted on", "Fee", "Microsoft 365"].join(",")] + for (const person of data().people) { + const member = person.member + if (!member) continue + lines.push( + [member.number, fullName(person), member.status, member.admittedOn, member.fee ?? "", member.m365Email] + .map((cell) => `"${cell.replaceAll('"', '""')}"`) + .join(",") + ) + } + return { filename: `member-register-${today()}.csv`, csv: lines.join("\n") } + }, + endAdmin: async (input) => { + require(viewer, "people:write") + const person = find(input.personId) + if (!person.admin || person.admin.status !== "active") throw new Error("This person isn't an active admin.") + Object.assign(person.admin, { + status: "former", + endedOn: today(), + endReason: input.reason, + groups: [], + requested: [], + capoScope: null, + }) + log(person, actor, `ended the admin role: ${input.reason}`) + }, + setCapoScope: async (input) => { + require(viewer, "people:appoint") + const person = find(input.personId) + if (!person.admin || person.admin.status !== "active") throw new Error("Only active admins can be Capo Admins.") + person.admin.capoScope = input.scope + log( + person, + actor, + input.scope ? `appointed ${person.profile.firstName} Capo Admin of ${input.scope}` : "ended the Capo Admin role" + ) + }, + listTeams: async () => { + const canRead = viewer.permissions.includes("people:read") + const leads = leadsOf(viewerRecord(viewer)) + if (!canRead && !leads.length) throw new Error("UNAUTHORIZED") + const teams: TeamSummary[] = [] + for (const id of TEAM_IDS) { + if (!canRead && !leads.includes(id)) continue + const members = data().people.filter((person) => person.teams.some((team) => team.team === id)) + const lead = members.find((person) => + person.teams.some((team) => team.team === id && team.role === "lead" && !team.endedOn) + ) + teams.push({ + id, + name: TEAMS[id].name, + lead: lead ? { id: lead.id, name: fullName(lead) } : null, + members: members.map((person) => row(person, true)), + }) + } + return teams + }, + addTeamMember: async (input) => { + const canWrite = viewer.permissions.includes("people:write") + if (!canWrite && !leadsOf(viewerRecord(viewer)).includes(input.teamId)) throw new Error("UNAUTHORIZED") + if (input.role === "lead") require(viewer, "people:appoint") + const person = find(input.personId) + if (person.teams.some((team) => team.team === input.teamId && !team.endedOn)) { + throw new Error(`${fullName(person)} is already in ${TEAMS[input.teamId].name}.`) + } + person.teams.push({ + team: input.teamId, + role: input.role, + since: input.since, + endedOn: null, + endReason: null, + fields: input.fields, + }) + log( + person, + actor, + `added ${person.profile.firstName} to ${TEAMS[input.teamId].name} as ${input.role === "lead" ? "Team Lead" : "Member"}` + ) + }, + updateTeamFields: async (input) => { + const canWrite = viewer.permissions.includes("people:write") + if (!canWrite && !leadsOf(viewerRecord(viewer)).includes(input.teamId)) throw new Error("UNAUTHORIZED") + const membership = find(input.personId).teams.find((team) => team.team === input.teamId && !team.endedOn) + if (!membership) throw new Error("This membership has ended.") + membership.fields = input.fields + }, + endTeamMembership: async (input) => { + const canWrite = viewer.permissions.includes("people:write") + if (!canWrite && !leadsOf(viewerRecord(viewer)).includes(input.teamId)) throw new Error("UNAUTHORIZED") + const person = find(input.personId) + const membership = person.teams.find((team) => team.team === input.teamId && !team.endedOn) + if (!membership) throw new Error("This membership has already ended.") + membership.endedOn = input.lastDay + membership.endReason = input.reason + log(person, actor, `ended the ${TEAMS[input.teamId].name} membership: ${input.reason}`) + }, + listCampaigns: async () => { + require(viewer, "people:read") + return campaigns() + }, + getCampaign: async (id) => { + require(viewer, "people:read") + const campaign = campaigns().find((candidate) => candidate.id === id) + if (!campaign) throw new Error("NOT_FOUND") + const people = + campaign.status === "open" + ? data().people.filter((person) => person.expected) + : data().people.filter((person) => person.census.some((entry) => entry.year === campaign.year)) + return { campaign, people: people.map((person) => row(person, true)) } satisfies CampaignDetail + }, + getAnswers: async (input) => { + require(viewer, "people:read") + const person = find(input.personId) + const entry = person.census.find((candidate) => candidate.year === input.year) + if (!entry?.answer) throw new Error("No answer for this year.") + return [ + { + question: `Will you continue volunteering in ${input.year}?`, + answer: entry.answer === "continues" ? "Yes" : "No", + }, + ...(entry.answer === "leaving" + ? [{ 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: "Telegram username", + answer: `@${person.censusTelegram ?? person.profile.telegramUsername ?? "—"}`, + }, + { + question: "Groups you administer", + answer: person.admin?.groups.map((group) => group.title).join(", ") || "—", + }, + { question: "Are you a PoliNetwork member (socio)?", answer: entry.declaredMember ? "Yes" : "No" }, + { question: "Available as Capo Admin?", answer: person.admin?.availableAsCapo ? "Yes" : "No" }, + { + question: "Photo and video consent", + answer: person.consents.photo?.status === "granted" ? "I agree" : "I don't agree", + }, + ] + }, + remind: async (input) => { + require(viewer, "people:write") + const targets = data().people.filter( + (person) => + person.expected && + (input.personId === null || person.id === input.personId) && + person.census.some((entry) => entry.year === "2026/27" && entry.answer === null) + ) + let sent = 0 + let skipped = 0 + for (const person of targets) { + const entry = person.census.find((candidate) => candidate.year === "2026/27") + if (!entry) continue + if (input.personId === null && entry.reminders >= 2) { + skipped++ + continue + } + entry.reminders++ + sent++ + } + return { sent, skipped } + }, + closeCampaign: async () => { + require(viewer, "people:write") + throw new Error("Closing a campaign isn't available with sample data.") + }, + importPreview: async (_campaignId, file) => { + require(viewer, "people:write") + const preview = buildPreview(file.name) + previews.set(preview.importId, preview) + return preview + }, + importCommit: async (input) => { + require(viewer, "people:write") + const preview = previews.get(input.importId) + if (!preview) throw new Error("This import expired. Upload the file again.") + importedFiles.add(preview.fileName) + previews.delete(input.importId) + return { imported: preview.rows - preview.counts.invalid - preview.counts.alreadyImported } + }, + listReviews: async () => { + require(viewer, "people:read") + return data().reviews + }, + resolveReview: async (input) => { + require(viewer, "people:write") + const store = data() + const review = store.reviews.find((candidate) => candidate.id === input.reviewId) + if (!review) throw new Error("This item was already resolved.") + store.reviews = store.reviews.filter((candidate) => candidate !== review) + const target = review.record ? store.byId.get(review.record.id) : undefined + if (target && ["link-record", "move-telegram", "link-first", "link-anyway"].includes(input.resolution)) { + target.account = { + status: "linked", + email: review.account.email, + via: review.account.via, + linkedOn: today(), + how: `Resolved by ${actor}`, + } + log(target, actor, `linked ${review.account.email}: ${input.reason}`) + } else if (target && target.account.status === "review") { + target.account = { status: "none" } + } + }, + dismissReview: async (input) => { + require(viewer, "people:write") + const store = data() + const review = store.reviews.find((candidate) => candidate.id === input.reviewId) + store.reviews = store.reviews.filter((candidate) => candidate.id !== input.reviewId) + const target = review?.record ? store.byId.get(review.record.id) : undefined + if (target?.account.status === "review") target.account = { status: "none" } + }, + listInvites: async () => { + require(viewer, "people:read") + return data().people.flatMap((person) => + person.account.status === "invite" + ? [ + { + personId: person.id, + name: fullName(person), + email: person.account.email, + sentOn: person.account.sentOn, + expiresOn: person.account.expiresOn, + sentBy: person.account.sentBy, + }, + ] + : [] + ) + }, + sendInvite: async (input) => { + require(viewer, "people:write") + const person = find(input.personId) + if (person.account.status === "linked") throw new Error(`${fullName(person)} has already linked an account.`) + if (!person.profile.personalEmail) throw new Error("Add a personal email before sending an invite.") + person.account = { + status: "invite", + email: person.profile.personalEmail, + sentOn: today(), + expiresOn: addDays(today(), 14), + sentBy: actor, + } + log(person, actor, `sent an invite to ${person.profile.personalEmail}`) + }, + revokeInvite: async (input) => { + require(viewer, "people:write") + const person = find(input.personId) + if (person.account.status !== "invite") throw new Error("There is no pending invite.") + person.account = { status: "none" } + log(person, actor, "revoked the invite") + }, + searchAccounts: async (input) => { + require(viewer, "people:write") + const person = find(input.personId) + const { firstName, lastName, personalEmail, telegramUsername } = person.profile + const otherFirst = `${firstName.slice(0, -1)}${firstName.endsWith("a") ? "o" : "a"}` + const accounts: AccountMatch[] = [ + { + id: "acc-1", + name: fullName(person), + email: personalEmail ?? `${firstName.toLowerCase()}@gmail.com`, + telegram: telegramUsername ? `@${telegramUsername}` : null, + via: "Signed in with Telegram on 13 Oct 2026", + linkedTo: null, + reason: "same-telegram", + }, + { + id: "acc-2", + name: `${firstName} ${lastName}ni`, + email: `${firstName[0].toLowerCase()}.${lastName.toLowerCase()}ni@outlook.it`, + telegram: null, + via: "Signed in with Google on 2 Oct 2026", + linkedTo: null, + reason: "similar-name", + }, + { + id: "acc-3", + name: `${otherFirst} ${lastName}`, + email: `${otherFirst.toLowerCase()}.${lastName.toLowerCase()}@mail.polimi.it`, + telegram: null, + via: "Signed in with Microsoft 365 on 28 Sep 2026", + linkedTo: { id: "P-0057", name: `${otherFirst} ${lastName}` }, + reason: "similar-name", + }, + ] + const query = input.query.toLowerCase().replace(/^@/, "") + return query + ? accounts.filter((account) => + `${account.name} ${account.email} ${account.telegram ?? ""}`.toLowerCase().includes(query) + ) + : accounts + }, + linkAccount: async (input) => { + require(viewer, "people:write") + const person = find(input.personId) + const accounts = await fakePeopleApi(viewer).searchAccounts({ personId: input.personId, query: "" }) + const account = accounts.find((candidate) => candidate.id === input.accountId) + if (!account) throw new Error("This account no longer exists.") + if (account.linkedTo) { + throw new Error( + `This account is already linked to ${account.linkedTo.name} (${account.linkedTo.id}). Unlink it there first, or pick another account.` + ) + } + person.account = { + status: "linked", + email: account.email, + via: account.via.split(" on ")[0].replace("Signed in with ", ""), + linkedOn: today(), + how: `Linked by ${actor}`, + } + log(person, actor, `linked ${account.email}: ${input.reason}`) + }, + unlinkAccount: async (input) => { + require(viewer, "people:write") + const person = find(input.personId) + if (person.account.status !== "linked") throw new Error("No account is linked.") + log(person, actor, `unlinked ${person.account.email}`) + person.account = { status: "none" } + }, + getCapoArea: async () => { + require(viewer, "people:self") + const me = viewerRecord(viewer) + const scope = me?.admin?.capoScope + const family = scope ? familyOfScope(scope) : undefined + if (!scope || !family) throw new Error("UNAUTHORIZED") + const titles = FAMILIES[family].groups + const admins = data().people.filter( + (person) => + person.admin?.status === "active" && person.admin.groups.some((group) => titles.includes(group.title)) + ) + return { + scope, + groups: titles.map((title) => ({ + chatId: GROUP_IDS.get(title) ?? "", + title, + admins: admins.filter((person) => person.admin?.groups.some((group) => group.title === title)).length, + })), + admins: admins.map((person) => ({ ...row(person, true), profile: { ...person.profile, personalEmail: null } })), + requests: data() + .joinRequests.filter((request) => titles.includes(request.group)) + .map((request) => { + const person = find(request.personId) + return { + id: request.id, + person: { id: person.id, name: fullName(person) }, + group: { chatId: GROUP_IDS.get(request.group) ?? "", title: request.group }, + requestedOn: request.requestedOn, + } + }), + } satisfies CapoArea + }, + approveJoinRequest: async (input) => { + require(viewer, "people:self") + const store = data() + const request = store.joinRequests.find((candidate) => candidate.id === input.requestId) + if (!request) throw new Error("This request was already handled.") + const person = find(request.personId) + if (person.admin && !person.admin.groups.some((group) => group.title === request.group)) { + person.admin.groups.push({ chatId: GROUP_IDS.get(request.group) ?? "", title: request.group }) + person.admin.requested = person.admin.requested.filter((group) => group.title !== request.group) + } + store.joinRequests = store.joinRequests.filter((candidate) => candidate !== request) + log(person, actor, `approved ${person.profile.firstName} as admin of ${request.group}`) + }, + declineJoinRequest: async (input) => { + require(viewer, "people:self") + const store = data() + const request = store.joinRequests.find((candidate) => candidate.id === input.requestId) + if (!request) throw new Error("This request was already handled.") + const person = find(request.personId) + if (person.admin) person.admin.requested = person.admin.requested.filter((group) => group.title !== request.group) + store.joinRequests = store.joinRequests.filter((candidate) => candidate !== request) + }, + getMyRoles: async () => { + const me = viewerRecord(viewer) + const linked = me && (!viewer.personId?.startsWith("match:") || self.matchConfirmed) + return { capoScope: linked ? (me.admin?.capoScope ?? null) : null, leads: linked ? leadsOf(me) : [] } + }, + getMyArea: async () => { + require(viewer, "people:self") + const me = viewerRecord(viewer) + if (!me || self.matchRejected) + return { state: "none", request: self.askedOn ? { sentOn: self.askedOn } : null } satisfies MyArea + if (viewer.personId?.startsWith("match:") && !self.matchConfirmed) { + const roles = [ + ...(me.member && me.member.status !== "former" ? ["Member"] : []), + ...(me.admin?.status === "active" ? ["Admin"] : []), + ...me.teams.filter((team) => !team.endedOn).map((team) => TEAMS[team.team].name), + ] + return { + state: "match", + candidate: { + id: me.id, + name: fullName(me), + course: me.profile.course, + year: me.profile.year, + campus: me.profile.campus, + roles, + }, + reason: me.profile.telegramUsername + ? `Matched on your verified Telegram account, @${me.profile.telegramUsername}.` + : "Matched on your email address.", + } satisfies MyArea + } + const contacts: Extract["contacts"] = [] + const capo = me.admin?.capoOfReference ? data().byId.get(me.admin.capoOfReference.id) : undefined + if (capo) + contacts.push({ + role: "capo", + name: fullName(capo), + telegram: capo.profile.telegramUsername, + email: null, + team: null, + }) + for (const membership of me.teams.filter((team) => !team.endedOn)) { + const lead = data().people.find( + (person) => + person !== me && + person.teams.some((team) => team.team === membership.team && team.role === "lead" && !team.endedOn) + ) + if (lead) + contacts.push({ + role: "lead", + name: fullName(lead), + telegram: lead.profile.telegramUsername, + email: null, + team: membership.team, + }) + } + contacts.push({ role: "hr", name: "HR team", telegram: null, email: "hr@polinetwork.org", team: null }) + const team = me.teams.find((membership) => !membership.endedOn)?.team ?? null + const roster = team + ? { + team, + members: data() + .people.flatMap((person) => { + const membership = person.teams.find((candidate) => candidate.team === team && !candidate.endedOn) + if (!membership) return [] + const email = + person.member || person.account.status === "linked" + ? m365Address(person.profile.firstName, person.profile.lastName) + : null + return [ + { + id: person.id, + name: fullName(person), + role: membership.role, + telegram: person.profile.telegramUsername, + email, + }, + ] + }) + .sort((a, b) => (a.role === b.role ? a.name.localeCompare(b.name) : a.role === "lead" ? -1 : 1)), + } + : null + return { + state: "linked", + person: { ...record(me, { ...viewer, personId: me.id }), notes: null }, + campaign: me.expected ? { id: "2026", year: "2026/27", closesOn: "2026-10-31" } : null, + contacts, + roster, + } satisfies MyArea + }, + confirmMatch: async () => { + require(viewer, "people:self") + const me = viewerRecord(viewer) + if (!me) throw new Error("There is no record to link.") + self.matchConfirmed = true + me.account = { + status: "linked", + email: me.profile.personalEmail ?? "", + via: "Telegram", + linkedOn: today(), + how: `Confirmed by ${fullName(me)} at first sign-in`, + } + }, + rejectMatch: async () => { + require(viewer, "people:self") + self.matchRejected = true + }, + askToBeAdded: async () => { + require(viewer, "people:self") + self.askedOn = today() + }, + answerCensus: async (input) => { + require(viewer, "people:self") + const me = viewerRecord(viewer) + if (!me) throw new Error("There is no record to answer for.") + me.census = me.census.filter((entry) => entry.year !== "2026/27") + me.census.unshift({ + year: "2026/27", + answer: input.answer, + answeredOn: today(), + source: "dashboard", + declaredMember: me.member !== null && me.member.status !== "former", + reminders: 0, + }) + Object.assign(me.profile, { course: input.course, year: input.year, campus: input.campus }) + log(me, fullName(me), `answered the 2026/27 census: ${input.answer === "continues" ? "continues" : "leaving"}`) + }, + requestCorrection: async (input) => { + require(viewer, "people:self") + const me = viewerRecord(viewer) + if (me) log(me, fullName(me), `asked HR to correct ${input.field.toLowerCase()}`) + }, + revokePhotoConsent: async () => { + require(viewer, "people:self") + const me = viewerRecord(viewer) + if (!me?.consents.photo || me.consents.photo.status !== "granted") + throw new Error("There is no photo and video consent to revoke.") + me.consents.photo = { status: "revoked", on: today() } + log(me, fullName(me), "revoked the photo and video consent") + }, + } +} diff --git a/src/features/people/people.fixtures.server.ts b/src/features/people/people.fixtures.server.ts index 50c7fddc..31649f3a 100644 --- a/src/features/people/people.fixtures.server.ts +++ b/src/features/people/people.fixtures.server.ts @@ -268,7 +268,7 @@ export const FAMILIES = { }, } satisfies Record -const FAMILY_IDS: FamilyId[] = ["inf", "ges", "ind", "ele", "des", "arc"] +export const FAMILY_IDS: FamilyId[] = ["inf", "ges", "ind", "ele", "des", "arc"] /** Stable fake Telegram chat ids per group title. */ export const GROUP_IDS: Map = new Map( diff --git a/src/features/people/people.functions.ts b/src/features/people/people.functions.ts new file mode 100644 index 00000000..25cffdeb --- /dev/null +++ b/src/features/people/people.functions.ts @@ -0,0 +1,10 @@ +import { createServerFn } from "@tanstack/react-start" + +import { peopleSelfMiddleware } from "@/server/auth.middleware" + +import { peopleApi } from "./people.api.server" + +/** The Capo Admin scope and led teams on the signed-in person's record; they open Capo Admin and their team page. */ +export const getMyPeopleRoles = createServerFn({ method: "GET" }) + .middleware([peopleSelfMiddleware]) + .handler(async ({ context }) => (await peopleApi(context)).getMyRoles()) diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 4589b3f3..c32652de 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -15,10 +15,11 @@ import { Route as DashboardRouteImport } from './routes/dashboard' import { Route as IndexRouteImport } from './routes/index' import { Route as DashboardIndexRouteImport } from './routes/dashboard/index' import { Route as DashboardWebRouteImport } from './routes/dashboard/web' -import { Route as DashboardAccountRouteImport } from './routes/dashboard/account' import { Route as AuthLogoutRouteImport } from './routes/auth/logout' import { Route as AuthLoginRouteImport } from './routes/auth/login' import { Route as AuthCallbackRouteImport } from './routes/auth/callback' +import { Route as DashboardPeopleIndexRouteImport } from './routes/dashboard/people/index' +import { Route as DashboardAccountIndexRouteImport } from './routes/dashboard/account/index' import { Route as DashboardWhatsappGroupsRouteImport } from './routes/dashboard/whatsapp/groups' import { Route as DashboardWebProjectsRouteImport } from './routes/dashboard/web/projects' import { Route as DashboardWebGuidesRouteImport } from './routes/dashboard/web/guides' @@ -27,15 +28,25 @@ import { Route as DashboardWebFaqsRouteImport } from './routes/dashboard/web/faq import { Route as DashboardWebAssociationsRouteImport } from './routes/dashboard/web/associations' import { Route as DashboardTelegramGroupsRouteImport } from './routes/dashboard/telegram/groups' import { Route as DashboardTelegramGrantsRouteImport } from './routes/dashboard/telegram/grants' +import { Route as DashboardTelegramCapoRouteImport } from './routes/dashboard/telegram/capo' import { Route as DashboardReportsResolvedRouteImport } from './routes/dashboard/reports/resolved' import { Route as DashboardReportsGroupLinksRouteImport } from './routes/dashboard/reports/group-links' +import { Route as DashboardPeopleMembersRouteImport } from './routes/dashboard/people/members' +import { Route as DashboardPeopleLinkingRouteImport } from './routes/dashboard/people/linking' +import { Route as DashboardPeopleAdminsRouteImport } from './routes/dashboard/people/admins' +import { Route as DashboardPeoplePersonIdRouteImport } from './routes/dashboard/people/$personId' import { Route as DashboardAzureMembersRouteImport } from './routes/dashboard/azure/members' import { Route as DashboardAzureGroupsRouteImport } from './routes/dashboard/azure/groups' +import { Route as DashboardAccountMeRouteImport } from './routes/dashboard/account/me' import { Route as DashboardWebGroupsByLabelIndexRouteImport } from './routes/dashboard/web/groups-by-label/index' import { Route as DashboardTelegramUsersIndexRouteImport } from './routes/dashboard/telegram/users/index' +import { Route as DashboardPeopleTeamsIndexRouteImport } from './routes/dashboard/people/teams/index' +import { Route as DashboardPeopleCensusIndexRouteImport } from './routes/dashboard/people/census/index' import { Route as DashboardWebTagsTagRouteImport } from './routes/dashboard/web/tags/$tag' import { Route as DashboardWebGroupsByLabelSplatRouteImport } from './routes/dashboard/web/groups-by-label/$' import { Route as DashboardTelegramUsersUserIdRouteImport } from './routes/dashboard/telegram/users/$userId' +import { Route as DashboardPeopleTeamsTeamIdRouteImport } from './routes/dashboard/people/teams/$teamId' +import { Route as DashboardPeopleCensusCampaignIdRouteImport } from './routes/dashboard/people/census/$campaignId' const UnauthorizedRoute = UnauthorizedRouteImport.update({ id: '/unauthorized', @@ -67,11 +78,6 @@ const DashboardWebRoute = DashboardWebRouteImport.update({ path: '/web', getParentRoute: () => DashboardRoute, } as any) -const DashboardAccountRoute = DashboardAccountRouteImport.update({ - id: '/account', - path: '/account', - getParentRoute: () => DashboardRoute, -} as any) const AuthLogoutRoute = AuthLogoutRouteImport.update({ id: '/auth/logout', path: '/auth/logout', @@ -87,6 +93,16 @@ const AuthCallbackRoute = AuthCallbackRouteImport.update({ path: '/auth/callback', getParentRoute: () => rootRouteImport, } as any) +const DashboardPeopleIndexRoute = DashboardPeopleIndexRouteImport.update({ + id: '/people/', + path: '/people/', + getParentRoute: () => DashboardRoute, +} as any) +const DashboardAccountIndexRoute = DashboardAccountIndexRouteImport.update({ + id: '/account/', + path: '/account/', + getParentRoute: () => DashboardRoute, +} as any) const DashboardWhatsappGroupsRoute = DashboardWhatsappGroupsRouteImport.update({ id: '/whatsapp/groups', path: '/whatsapp/groups', @@ -128,6 +144,11 @@ const DashboardTelegramGrantsRoute = DashboardTelegramGrantsRouteImport.update({ path: '/telegram/grants', getParentRoute: () => DashboardRoute, } as any) +const DashboardTelegramCapoRoute = DashboardTelegramCapoRouteImport.update({ + id: '/telegram/capo', + path: '/telegram/capo', + getParentRoute: () => DashboardRoute, +} as any) const DashboardReportsResolvedRoute = DashboardReportsResolvedRouteImport.update({ id: '/reports/resolved', @@ -140,6 +161,26 @@ const DashboardReportsGroupLinksRoute = path: '/reports/group-links', getParentRoute: () => DashboardRoute, } as any) +const DashboardPeopleMembersRoute = DashboardPeopleMembersRouteImport.update({ + id: '/people/members', + path: '/people/members', + getParentRoute: () => DashboardRoute, +} as any) +const DashboardPeopleLinkingRoute = DashboardPeopleLinkingRouteImport.update({ + id: '/people/linking', + path: '/people/linking', + getParentRoute: () => DashboardRoute, +} as any) +const DashboardPeopleAdminsRoute = DashboardPeopleAdminsRouteImport.update({ + id: '/people/admins', + path: '/people/admins', + getParentRoute: () => DashboardRoute, +} as any) +const DashboardPeoplePersonIdRoute = DashboardPeoplePersonIdRouteImport.update({ + id: '/people/$personId', + path: '/people/$personId', + getParentRoute: () => DashboardRoute, +} as any) const DashboardAzureMembersRoute = DashboardAzureMembersRouteImport.update({ id: '/azure/members', path: '/azure/members', @@ -150,6 +191,11 @@ const DashboardAzureGroupsRoute = DashboardAzureGroupsRouteImport.update({ path: '/azure/groups', getParentRoute: () => DashboardRoute, } as any) +const DashboardAccountMeRoute = DashboardAccountMeRouteImport.update({ + id: '/account/me', + path: '/account/me', + getParentRoute: () => DashboardRoute, +} as any) const DashboardWebGroupsByLabelIndexRoute = DashboardWebGroupsByLabelIndexRouteImport.update({ id: '/groups-by-label/', @@ -162,6 +208,18 @@ const DashboardTelegramUsersIndexRoute = path: '/telegram/users/', getParentRoute: () => DashboardRoute, } as any) +const DashboardPeopleTeamsIndexRoute = + DashboardPeopleTeamsIndexRouteImport.update({ + id: '/people/teams/', + path: '/people/teams/', + getParentRoute: () => DashboardRoute, + } as any) +const DashboardPeopleCensusIndexRoute = + DashboardPeopleCensusIndexRouteImport.update({ + id: '/people/census/', + path: '/people/census/', + getParentRoute: () => DashboardRoute, + } as any) const DashboardWebTagsTagRoute = DashboardWebTagsTagRouteImport.update({ id: '/tags/$tag', path: '/tags/$tag', @@ -179,6 +237,18 @@ const DashboardTelegramUsersUserIdRoute = path: '/telegram/users/$userId', getParentRoute: () => DashboardRoute, } as any) +const DashboardPeopleTeamsTeamIdRoute = + DashboardPeopleTeamsTeamIdRouteImport.update({ + id: '/people/teams/$teamId', + path: '/people/teams/$teamId', + getParentRoute: () => DashboardRoute, + } as any) +const DashboardPeopleCensusCampaignIdRoute = + DashboardPeopleCensusCampaignIdRouteImport.update({ + id: '/people/census/$campaignId', + path: '/people/census/$campaignId', + getParentRoute: () => DashboardRoute, + } as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute @@ -188,13 +258,18 @@ export interface FileRoutesByFullPath { '/auth/callback': typeof AuthCallbackRoute '/auth/login': typeof AuthLoginRoute '/auth/logout': typeof AuthLogoutRoute - '/dashboard/account': typeof DashboardAccountRoute '/dashboard/web': typeof DashboardWebRouteWithChildren '/dashboard/': typeof DashboardIndexRoute + '/dashboard/account/me': typeof DashboardAccountMeRoute '/dashboard/azure/groups': typeof DashboardAzureGroupsRoute '/dashboard/azure/members': typeof DashboardAzureMembersRoute + '/dashboard/people/$personId': typeof DashboardPeoplePersonIdRoute + '/dashboard/people/admins': typeof DashboardPeopleAdminsRoute + '/dashboard/people/linking': typeof DashboardPeopleLinkingRoute + '/dashboard/people/members': typeof DashboardPeopleMembersRoute '/dashboard/reports/group-links': typeof DashboardReportsGroupLinksRoute '/dashboard/reports/resolved': typeof DashboardReportsResolvedRoute + '/dashboard/telegram/capo': typeof DashboardTelegramCapoRoute '/dashboard/telegram/grants': typeof DashboardTelegramGrantsRoute '/dashboard/telegram/groups': typeof DashboardTelegramGroupsRoute '/dashboard/web/associations': typeof DashboardWebAssociationsRoute @@ -203,9 +278,15 @@ export interface FileRoutesByFullPath { '/dashboard/web/guides': typeof DashboardWebGuidesRoute '/dashboard/web/projects': typeof DashboardWebProjectsRoute '/dashboard/whatsapp/groups': typeof DashboardWhatsappGroupsRoute + '/dashboard/account/': typeof DashboardAccountIndexRoute + '/dashboard/people/': typeof DashboardPeopleIndexRoute + '/dashboard/people/census/$campaignId': typeof DashboardPeopleCensusCampaignIdRoute + '/dashboard/people/teams/$teamId': typeof DashboardPeopleTeamsTeamIdRoute '/dashboard/telegram/users/$userId': typeof DashboardTelegramUsersUserIdRoute '/dashboard/web/groups-by-label/$': typeof DashboardWebGroupsByLabelSplatRoute '/dashboard/web/tags/$tag': typeof DashboardWebTagsTagRoute + '/dashboard/people/census/': typeof DashboardPeopleCensusIndexRoute + '/dashboard/people/teams/': typeof DashboardPeopleTeamsIndexRoute '/dashboard/telegram/users/': typeof DashboardTelegramUsersIndexRoute '/dashboard/web/groups-by-label/': typeof DashboardWebGroupsByLabelIndexRoute } @@ -216,13 +297,18 @@ export interface FileRoutesByTo { '/auth/callback': typeof AuthCallbackRoute '/auth/login': typeof AuthLoginRoute '/auth/logout': typeof AuthLogoutRoute - '/dashboard/account': typeof DashboardAccountRoute '/dashboard/web': typeof DashboardWebRouteWithChildren '/dashboard': typeof DashboardIndexRoute + '/dashboard/account/me': typeof DashboardAccountMeRoute '/dashboard/azure/groups': typeof DashboardAzureGroupsRoute '/dashboard/azure/members': typeof DashboardAzureMembersRoute + '/dashboard/people/$personId': typeof DashboardPeoplePersonIdRoute + '/dashboard/people/admins': typeof DashboardPeopleAdminsRoute + '/dashboard/people/linking': typeof DashboardPeopleLinkingRoute + '/dashboard/people/members': typeof DashboardPeopleMembersRoute '/dashboard/reports/group-links': typeof DashboardReportsGroupLinksRoute '/dashboard/reports/resolved': typeof DashboardReportsResolvedRoute + '/dashboard/telegram/capo': typeof DashboardTelegramCapoRoute '/dashboard/telegram/grants': typeof DashboardTelegramGrantsRoute '/dashboard/telegram/groups': typeof DashboardTelegramGroupsRoute '/dashboard/web/associations': typeof DashboardWebAssociationsRoute @@ -231,9 +317,15 @@ export interface FileRoutesByTo { '/dashboard/web/guides': typeof DashboardWebGuidesRoute '/dashboard/web/projects': typeof DashboardWebProjectsRoute '/dashboard/whatsapp/groups': typeof DashboardWhatsappGroupsRoute + '/dashboard/account': typeof DashboardAccountIndexRoute + '/dashboard/people': typeof DashboardPeopleIndexRoute + '/dashboard/people/census/$campaignId': typeof DashboardPeopleCensusCampaignIdRoute + '/dashboard/people/teams/$teamId': typeof DashboardPeopleTeamsTeamIdRoute '/dashboard/telegram/users/$userId': typeof DashboardTelegramUsersUserIdRoute '/dashboard/web/groups-by-label/$': typeof DashboardWebGroupsByLabelSplatRoute '/dashboard/web/tags/$tag': typeof DashboardWebTagsTagRoute + '/dashboard/people/census': typeof DashboardPeopleCensusIndexRoute + '/dashboard/people/teams': typeof DashboardPeopleTeamsIndexRoute '/dashboard/telegram/users': typeof DashboardTelegramUsersIndexRoute '/dashboard/web/groups-by-label': typeof DashboardWebGroupsByLabelIndexRoute } @@ -246,13 +338,18 @@ export interface FileRoutesById { '/auth/callback': typeof AuthCallbackRoute '/auth/login': typeof AuthLoginRoute '/auth/logout': typeof AuthLogoutRoute - '/dashboard/account': typeof DashboardAccountRoute '/dashboard/web': typeof DashboardWebRouteWithChildren '/dashboard/': typeof DashboardIndexRoute + '/dashboard/account/me': typeof DashboardAccountMeRoute '/dashboard/azure/groups': typeof DashboardAzureGroupsRoute '/dashboard/azure/members': typeof DashboardAzureMembersRoute + '/dashboard/people/$personId': typeof DashboardPeoplePersonIdRoute + '/dashboard/people/admins': typeof DashboardPeopleAdminsRoute + '/dashboard/people/linking': typeof DashboardPeopleLinkingRoute + '/dashboard/people/members': typeof DashboardPeopleMembersRoute '/dashboard/reports/group-links': typeof DashboardReportsGroupLinksRoute '/dashboard/reports/resolved': typeof DashboardReportsResolvedRoute + '/dashboard/telegram/capo': typeof DashboardTelegramCapoRoute '/dashboard/telegram/grants': typeof DashboardTelegramGrantsRoute '/dashboard/telegram/groups': typeof DashboardTelegramGroupsRoute '/dashboard/web/associations': typeof DashboardWebAssociationsRoute @@ -261,9 +358,15 @@ export interface FileRoutesById { '/dashboard/web/guides': typeof DashboardWebGuidesRoute '/dashboard/web/projects': typeof DashboardWebProjectsRoute '/dashboard/whatsapp/groups': typeof DashboardWhatsappGroupsRoute + '/dashboard/account/': typeof DashboardAccountIndexRoute + '/dashboard/people/': typeof DashboardPeopleIndexRoute + '/dashboard/people/census/$campaignId': typeof DashboardPeopleCensusCampaignIdRoute + '/dashboard/people/teams/$teamId': typeof DashboardPeopleTeamsTeamIdRoute '/dashboard/telegram/users/$userId': typeof DashboardTelegramUsersUserIdRoute '/dashboard/web/groups-by-label/$': typeof DashboardWebGroupsByLabelSplatRoute '/dashboard/web/tags/$tag': typeof DashboardWebTagsTagRoute + '/dashboard/people/census/': typeof DashboardPeopleCensusIndexRoute + '/dashboard/people/teams/': typeof DashboardPeopleTeamsIndexRoute '/dashboard/telegram/users/': typeof DashboardTelegramUsersIndexRoute '/dashboard/web/groups-by-label/': typeof DashboardWebGroupsByLabelIndexRoute } @@ -277,13 +380,18 @@ export interface FileRouteTypes { | '/auth/callback' | '/auth/login' | '/auth/logout' - | '/dashboard/account' | '/dashboard/web' | '/dashboard/' + | '/dashboard/account/me' | '/dashboard/azure/groups' | '/dashboard/azure/members' + | '/dashboard/people/$personId' + | '/dashboard/people/admins' + | '/dashboard/people/linking' + | '/dashboard/people/members' | '/dashboard/reports/group-links' | '/dashboard/reports/resolved' + | '/dashboard/telegram/capo' | '/dashboard/telegram/grants' | '/dashboard/telegram/groups' | '/dashboard/web/associations' @@ -292,9 +400,15 @@ export interface FileRouteTypes { | '/dashboard/web/guides' | '/dashboard/web/projects' | '/dashboard/whatsapp/groups' + | '/dashboard/account/' + | '/dashboard/people/' + | '/dashboard/people/census/$campaignId' + | '/dashboard/people/teams/$teamId' | '/dashboard/telegram/users/$userId' | '/dashboard/web/groups-by-label/$' | '/dashboard/web/tags/$tag' + | '/dashboard/people/census/' + | '/dashboard/people/teams/' | '/dashboard/telegram/users/' | '/dashboard/web/groups-by-label/' fileRoutesByTo: FileRoutesByTo @@ -305,13 +419,18 @@ export interface FileRouteTypes { | '/auth/callback' | '/auth/login' | '/auth/logout' - | '/dashboard/account' | '/dashboard/web' | '/dashboard' + | '/dashboard/account/me' | '/dashboard/azure/groups' | '/dashboard/azure/members' + | '/dashboard/people/$personId' + | '/dashboard/people/admins' + | '/dashboard/people/linking' + | '/dashboard/people/members' | '/dashboard/reports/group-links' | '/dashboard/reports/resolved' + | '/dashboard/telegram/capo' | '/dashboard/telegram/grants' | '/dashboard/telegram/groups' | '/dashboard/web/associations' @@ -320,9 +439,15 @@ export interface FileRouteTypes { | '/dashboard/web/guides' | '/dashboard/web/projects' | '/dashboard/whatsapp/groups' + | '/dashboard/account' + | '/dashboard/people' + | '/dashboard/people/census/$campaignId' + | '/dashboard/people/teams/$teamId' | '/dashboard/telegram/users/$userId' | '/dashboard/web/groups-by-label/$' | '/dashboard/web/tags/$tag' + | '/dashboard/people/census' + | '/dashboard/people/teams' | '/dashboard/telegram/users' | '/dashboard/web/groups-by-label' id: @@ -334,13 +459,18 @@ export interface FileRouteTypes { | '/auth/callback' | '/auth/login' | '/auth/logout' - | '/dashboard/account' | '/dashboard/web' | '/dashboard/' + | '/dashboard/account/me' | '/dashboard/azure/groups' | '/dashboard/azure/members' + | '/dashboard/people/$personId' + | '/dashboard/people/admins' + | '/dashboard/people/linking' + | '/dashboard/people/members' | '/dashboard/reports/group-links' | '/dashboard/reports/resolved' + | '/dashboard/telegram/capo' | '/dashboard/telegram/grants' | '/dashboard/telegram/groups' | '/dashboard/web/associations' @@ -349,9 +479,15 @@ export interface FileRouteTypes { | '/dashboard/web/guides' | '/dashboard/web/projects' | '/dashboard/whatsapp/groups' + | '/dashboard/account/' + | '/dashboard/people/' + | '/dashboard/people/census/$campaignId' + | '/dashboard/people/teams/$teamId' | '/dashboard/telegram/users/$userId' | '/dashboard/web/groups-by-label/$' | '/dashboard/web/tags/$tag' + | '/dashboard/people/census/' + | '/dashboard/people/teams/' | '/dashboard/telegram/users/' | '/dashboard/web/groups-by-label/' fileRoutesById: FileRoutesById @@ -410,13 +546,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DashboardWebRouteImport parentRoute: typeof DashboardRoute } - '/dashboard/account': { - id: '/dashboard/account' - path: '/account' - fullPath: '/dashboard/account' - preLoaderRoute: typeof DashboardAccountRouteImport - parentRoute: typeof DashboardRoute - } '/auth/logout': { id: '/auth/logout' path: '/auth/logout' @@ -438,6 +567,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthCallbackRouteImport parentRoute: typeof rootRouteImport } + '/dashboard/people/': { + id: '/dashboard/people/' + path: '/people' + fullPath: '/dashboard/people/' + preLoaderRoute: typeof DashboardPeopleIndexRouteImport + parentRoute: typeof DashboardRoute + } + '/dashboard/account/': { + id: '/dashboard/account/' + path: '/account' + fullPath: '/dashboard/account/' + preLoaderRoute: typeof DashboardAccountIndexRouteImport + parentRoute: typeof DashboardRoute + } '/dashboard/whatsapp/groups': { id: '/dashboard/whatsapp/groups' path: '/whatsapp/groups' @@ -494,6 +637,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DashboardTelegramGrantsRouteImport parentRoute: typeof DashboardRoute } + '/dashboard/telegram/capo': { + id: '/dashboard/telegram/capo' + path: '/telegram/capo' + fullPath: '/dashboard/telegram/capo' + preLoaderRoute: typeof DashboardTelegramCapoRouteImport + parentRoute: typeof DashboardRoute + } '/dashboard/reports/resolved': { id: '/dashboard/reports/resolved' path: '/reports/resolved' @@ -508,6 +658,34 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DashboardReportsGroupLinksRouteImport parentRoute: typeof DashboardRoute } + '/dashboard/people/members': { + id: '/dashboard/people/members' + path: '/people/members' + fullPath: '/dashboard/people/members' + preLoaderRoute: typeof DashboardPeopleMembersRouteImport + parentRoute: typeof DashboardRoute + } + '/dashboard/people/linking': { + id: '/dashboard/people/linking' + path: '/people/linking' + fullPath: '/dashboard/people/linking' + preLoaderRoute: typeof DashboardPeopleLinkingRouteImport + parentRoute: typeof DashboardRoute + } + '/dashboard/people/admins': { + id: '/dashboard/people/admins' + path: '/people/admins' + fullPath: '/dashboard/people/admins' + preLoaderRoute: typeof DashboardPeopleAdminsRouteImport + parentRoute: typeof DashboardRoute + } + '/dashboard/people/$personId': { + id: '/dashboard/people/$personId' + path: '/people/$personId' + fullPath: '/dashboard/people/$personId' + preLoaderRoute: typeof DashboardPeoplePersonIdRouteImport + parentRoute: typeof DashboardRoute + } '/dashboard/azure/members': { id: '/dashboard/azure/members' path: '/azure/members' @@ -522,6 +700,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DashboardAzureGroupsRouteImport parentRoute: typeof DashboardRoute } + '/dashboard/account/me': { + id: '/dashboard/account/me' + path: '/account/me' + fullPath: '/dashboard/account/me' + preLoaderRoute: typeof DashboardAccountMeRouteImport + parentRoute: typeof DashboardRoute + } '/dashboard/web/groups-by-label/': { id: '/dashboard/web/groups-by-label/' path: '/groups-by-label' @@ -536,6 +721,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DashboardTelegramUsersIndexRouteImport parentRoute: typeof DashboardRoute } + '/dashboard/people/teams/': { + id: '/dashboard/people/teams/' + path: '/people/teams' + fullPath: '/dashboard/people/teams/' + preLoaderRoute: typeof DashboardPeopleTeamsIndexRouteImport + parentRoute: typeof DashboardRoute + } + '/dashboard/people/census/': { + id: '/dashboard/people/census/' + path: '/people/census' + fullPath: '/dashboard/people/census/' + preLoaderRoute: typeof DashboardPeopleCensusIndexRouteImport + parentRoute: typeof DashboardRoute + } '/dashboard/web/tags/$tag': { id: '/dashboard/web/tags/$tag' path: '/tags/$tag' @@ -557,6 +756,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DashboardTelegramUsersUserIdRouteImport parentRoute: typeof DashboardRoute } + '/dashboard/people/teams/$teamId': { + id: '/dashboard/people/teams/$teamId' + path: '/people/teams/$teamId' + fullPath: '/dashboard/people/teams/$teamId' + preLoaderRoute: typeof DashboardPeopleTeamsTeamIdRouteImport + parentRoute: typeof DashboardRoute + } + '/dashboard/people/census/$campaignId': { + id: '/dashboard/people/census/$campaignId' + path: '/people/census/$campaignId' + fullPath: '/dashboard/people/census/$campaignId' + preLoaderRoute: typeof DashboardPeopleCensusCampaignIdRouteImport + parentRoute: typeof DashboardRoute + } } } @@ -587,32 +800,54 @@ const DashboardWebRouteWithChildren = DashboardWebRoute._addFileChildren( ) interface DashboardRouteChildren { - DashboardAccountRoute: typeof DashboardAccountRoute DashboardWebRoute: typeof DashboardWebRouteWithChildren DashboardIndexRoute: typeof DashboardIndexRoute + DashboardAccountMeRoute: typeof DashboardAccountMeRoute DashboardAzureGroupsRoute: typeof DashboardAzureGroupsRoute DashboardAzureMembersRoute: typeof DashboardAzureMembersRoute + DashboardPeoplePersonIdRoute: typeof DashboardPeoplePersonIdRoute + DashboardPeopleAdminsRoute: typeof DashboardPeopleAdminsRoute + DashboardPeopleLinkingRoute: typeof DashboardPeopleLinkingRoute + DashboardPeopleMembersRoute: typeof DashboardPeopleMembersRoute DashboardReportsGroupLinksRoute: typeof DashboardReportsGroupLinksRoute DashboardReportsResolvedRoute: typeof DashboardReportsResolvedRoute + DashboardTelegramCapoRoute: typeof DashboardTelegramCapoRoute DashboardTelegramGrantsRoute: typeof DashboardTelegramGrantsRoute DashboardTelegramGroupsRoute: typeof DashboardTelegramGroupsRoute DashboardWhatsappGroupsRoute: typeof DashboardWhatsappGroupsRoute + DashboardAccountIndexRoute: typeof DashboardAccountIndexRoute + DashboardPeopleIndexRoute: typeof DashboardPeopleIndexRoute + DashboardPeopleCensusCampaignIdRoute: typeof DashboardPeopleCensusCampaignIdRoute + DashboardPeopleTeamsTeamIdRoute: typeof DashboardPeopleTeamsTeamIdRoute DashboardTelegramUsersUserIdRoute: typeof DashboardTelegramUsersUserIdRoute + DashboardPeopleCensusIndexRoute: typeof DashboardPeopleCensusIndexRoute + DashboardPeopleTeamsIndexRoute: typeof DashboardPeopleTeamsIndexRoute DashboardTelegramUsersIndexRoute: typeof DashboardTelegramUsersIndexRoute } const DashboardRouteChildren: DashboardRouteChildren = { - DashboardAccountRoute: DashboardAccountRoute, DashboardWebRoute: DashboardWebRouteWithChildren, DashboardIndexRoute: DashboardIndexRoute, + DashboardAccountMeRoute: DashboardAccountMeRoute, DashboardAzureGroupsRoute: DashboardAzureGroupsRoute, DashboardAzureMembersRoute: DashboardAzureMembersRoute, + DashboardPeoplePersonIdRoute: DashboardPeoplePersonIdRoute, + DashboardPeopleAdminsRoute: DashboardPeopleAdminsRoute, + DashboardPeopleLinkingRoute: DashboardPeopleLinkingRoute, + DashboardPeopleMembersRoute: DashboardPeopleMembersRoute, DashboardReportsGroupLinksRoute: DashboardReportsGroupLinksRoute, DashboardReportsResolvedRoute: DashboardReportsResolvedRoute, + DashboardTelegramCapoRoute: DashboardTelegramCapoRoute, DashboardTelegramGrantsRoute: DashboardTelegramGrantsRoute, DashboardTelegramGroupsRoute: DashboardTelegramGroupsRoute, DashboardWhatsappGroupsRoute: DashboardWhatsappGroupsRoute, + DashboardAccountIndexRoute: DashboardAccountIndexRoute, + DashboardPeopleIndexRoute: DashboardPeopleIndexRoute, + DashboardPeopleCensusCampaignIdRoute: DashboardPeopleCensusCampaignIdRoute, + DashboardPeopleTeamsTeamIdRoute: DashboardPeopleTeamsTeamIdRoute, DashboardTelegramUsersUserIdRoute: DashboardTelegramUsersUserIdRoute, + DashboardPeopleCensusIndexRoute: DashboardPeopleCensusIndexRoute, + DashboardPeopleTeamsIndexRoute: DashboardPeopleTeamsIndexRoute, DashboardTelegramUsersIndexRoute: DashboardTelegramUsersIndexRoute, } diff --git a/src/routes/dashboard.tsx b/src/routes/dashboard.tsx index b20a7bb1..e832ab03 100644 --- a/src/routes/dashboard.tsx +++ b/src/routes/dashboard.tsx @@ -1,20 +1,33 @@ import { createFileRoute, redirect } from "@tanstack/react-router" import { RouteNotFound } from "@/components/route-error" -import { DashboardShell, documentTitle, matchPath } from "@/components/shell" +import { canOpenPage, DashboardShell, documentTitle, matchPath, NO_PEOPLE_ROLES } from "@/components/shell" import { getDashboardAccess } from "@/features/auth/auth.functions" import { getPendingGroupLinkReports } from "@/features/group-link-reports/reports.functions" +import { getMyPeopleRoles } from "@/features/people/people.functions" import { signInRedirect } from "@/lib/sign-in" import { hasPermission } from "@/server/permissions" export const Route = createFileRoute("/dashboard")({ - // Signed in, then `admin:access` from `me.access` (RFC v3 §11.3). The permissions go into the route context for - // `useCan`; the backend enforces them on every call regardless. + // Signed in, then `admin:access` or `people:self` from `me.access` (RFC v3 §11.3). The permissions and the roles + // from the person's People record go into the route context for `useCan` and the navigation; pages the user can't + // open send them to their own area. The backend enforces every call regardless. beforeLoad: async ({ location }) => { const access = await getDashboardAccess() if (access.status === "unauthenticated") throw signInRedirect(location.href) if (access.status === "forbidden") throw redirect({ to: "/unauthorized" }) - return { user: access.user, permissions: access.permissions } + const peopleRoles = hasPermission(access.permissions, "people:self") + ? await getMyPeopleRoles().catch((error) => { + console.error(error) + return NO_PEOPLE_ROLES + }) + : NO_PEOPLE_ROLES + if (!canOpenPage(matchPath(location.pathname), access.permissions, peopleRoles)) { + throw redirect({ + to: hasPermission(access.permissions, "people:self") ? "/dashboard/account/me" : "/unauthorized", + }) + } + return { user: access.user, permissions: access.permissions, peopleRoles } }, // Not awaited: the panel count streams in. It is decoration, so a failed load hides it; the Reports page reports // the error. `router.invalidate()` (e.g. after resolving a report) refreshes it. Reading reports needs the same diff --git a/src/routes/dashboard/account.tsx b/src/routes/dashboard/account/index.tsx similarity index 87% rename from src/routes/dashboard/account.tsx rename to src/routes/dashboard/account/index.tsx index 07c3a00b..608a1cda 100644 --- a/src/routes/dashboard/account.tsx +++ b/src/routes/dashboard/account/index.tsx @@ -3,7 +3,7 @@ import { createFileRoute } from "@tanstack/react-router" import { AccountPage } from "@/features/account/account-page" import { getIdpAccountUrl } from "@/features/auth/auth.functions" -export const Route = createFileRoute("/dashboard/account")({ +export const Route = createFileRoute("/dashboard/account/")({ loader: () => getIdpAccountUrl(), component: AccountRoute, }) diff --git a/src/routes/dashboard/account/me.tsx b/src/routes/dashboard/account/me.tsx new file mode 100644 index 00000000..4a865530 --- /dev/null +++ b/src/routes/dashboard/account/me.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router" + +import { PagePlaceholder } from "@/features/people/page-placeholder" + +export const Route = createFileRoute("/dashboard/account/me")({ + component: PagePlaceholder, +}) diff --git a/src/routes/dashboard/people/$personId.tsx b/src/routes/dashboard/people/$personId.tsx new file mode 100644 index 00000000..bf72ee1a --- /dev/null +++ b/src/routes/dashboard/people/$personId.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router" + +import { PagePlaceholder } from "@/features/people/page-placeholder" + +export const Route = createFileRoute("/dashboard/people/$personId")({ + component: PagePlaceholder, +}) diff --git a/src/routes/dashboard/people/admins.tsx b/src/routes/dashboard/people/admins.tsx new file mode 100644 index 00000000..5b31a537 --- /dev/null +++ b/src/routes/dashboard/people/admins.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router" + +import { PagePlaceholder } from "@/features/people/page-placeholder" + +export const Route = createFileRoute("/dashboard/people/admins")({ + component: PagePlaceholder, +}) diff --git a/src/routes/dashboard/people/census/$campaignId.tsx b/src/routes/dashboard/people/census/$campaignId.tsx new file mode 100644 index 00000000..303caa48 --- /dev/null +++ b/src/routes/dashboard/people/census/$campaignId.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router" + +import { PagePlaceholder } from "@/features/people/page-placeholder" + +export const Route = createFileRoute("/dashboard/people/census/$campaignId")({ + component: PagePlaceholder, +}) diff --git a/src/routes/dashboard/people/census/index.tsx b/src/routes/dashboard/people/census/index.tsx new file mode 100644 index 00000000..7ecbe8bc --- /dev/null +++ b/src/routes/dashboard/people/census/index.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router" + +import { PagePlaceholder } from "@/features/people/page-placeholder" + +export const Route = createFileRoute("/dashboard/people/census/")({ + component: PagePlaceholder, +}) diff --git a/src/routes/dashboard/people/index.tsx b/src/routes/dashboard/people/index.tsx new file mode 100644 index 00000000..da444329 --- /dev/null +++ b/src/routes/dashboard/people/index.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router" + +import { PagePlaceholder } from "@/features/people/page-placeholder" + +export const Route = createFileRoute("/dashboard/people/")({ + component: PagePlaceholder, +}) diff --git a/src/routes/dashboard/people/linking.tsx b/src/routes/dashboard/people/linking.tsx new file mode 100644 index 00000000..ffc09ecc --- /dev/null +++ b/src/routes/dashboard/people/linking.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router" + +import { PagePlaceholder } from "@/features/people/page-placeholder" + +export const Route = createFileRoute("/dashboard/people/linking")({ + component: PagePlaceholder, +}) diff --git a/src/routes/dashboard/people/members.tsx b/src/routes/dashboard/people/members.tsx new file mode 100644 index 00000000..293df00b --- /dev/null +++ b/src/routes/dashboard/people/members.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router" + +import { PagePlaceholder } from "@/features/people/page-placeholder" + +export const Route = createFileRoute("/dashboard/people/members")({ + component: PagePlaceholder, +}) diff --git a/src/routes/dashboard/people/teams/$teamId.tsx b/src/routes/dashboard/people/teams/$teamId.tsx new file mode 100644 index 00000000..3e48d3eb --- /dev/null +++ b/src/routes/dashboard/people/teams/$teamId.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router" + +import { PagePlaceholder } from "@/features/people/page-placeholder" + +export const Route = createFileRoute("/dashboard/people/teams/$teamId")({ + component: PagePlaceholder, +}) diff --git a/src/routes/dashboard/people/teams/index.tsx b/src/routes/dashboard/people/teams/index.tsx new file mode 100644 index 00000000..a01a2709 --- /dev/null +++ b/src/routes/dashboard/people/teams/index.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router" + +import { PagePlaceholder } from "@/features/people/page-placeholder" + +export const Route = createFileRoute("/dashboard/people/teams/")({ + component: PagePlaceholder, +}) diff --git a/src/routes/dashboard/telegram/capo.tsx b/src/routes/dashboard/telegram/capo.tsx new file mode 100644 index 00000000..2f11e7e0 --- /dev/null +++ b/src/routes/dashboard/telegram/capo.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router" + +import { PagePlaceholder } from "@/features/people/page-placeholder" + +export const Route = createFileRoute("/dashboard/telegram/capo")({ + component: PagePlaceholder, +}) diff --git a/src/server/backend.server.ts b/src/server/backend.server.ts index 09374af4..528302d5 100644 --- a/src/server/backend.server.ts +++ b/src/server/backend.server.ts @@ -1,5 +1,12 @@ import { type AppRouter, TRPC_PATH } from "@polinetwork/backend" -import { createTRPCClient, httpBatchLink, httpLink, isNonJsonSerializable, splitLink } from "@trpc/client" +import { + createTRPCClient, + createTRPCUntypedClient, + httpBatchLink, + httpLink, + isNonJsonSerializable, + splitLink, +} from "@trpc/client" import { SuperJSON } from "superjson" import { env } from "@/env" @@ -9,25 +16,34 @@ import { env } from "@/env" * and enforces the user's permissions. `null` only in agent mode, which uses the backend's legacy anonymous path. */ export function createBackendClient(accessToken: string | null) { + return createTRPCClient({ links: backendLinks(accessToken) }) +} + +/** + * The same connection without the router's types, for procedures the published `AppRouter` doesn't declare yet + * (`people.*`). Callers must parse every response with the contract's schemas. + */ +export function createUntypedBackendClient(accessToken: string | null) { + return createTRPCUntypedClient({ links: backendLinks(accessToken) }) +} + +function backendLinks(accessToken: string | null) { const headers = accessToken ? { authorization: `Bearer ${accessToken}` } : undefined const url = `${env.BACKEND_URL}${TRPC_PATH}` - - return createTRPCClient({ - links: [ - splitLink({ - condition: (operation) => isNonJsonSerializable(operation.input), - true: httpLink({ - url, - headers, - transformer: { - serialize: (data) => data, - deserialize: (data) => SuperJSON.deserialize(data), - }, - }), - false: httpBatchLink({ url, headers, transformer: SuperJSON }), + return [ + splitLink({ + condition: (operation) => isNonJsonSerializable(operation.input), + true: httpLink({ + url, + headers, + transformer: { + serialize: (data) => data, + deserialize: (data) => SuperJSON.deserialize(data), + }, }), - ], - }) + false: httpBatchLink({ url, headers, transformer: SuperJSON }), + }), + ] } export type BackendClient = ReturnType diff --git a/tests/server-security.test.mjs b/tests/server-security.test.mjs index 5b62deaf..7fe8de23 100644 --- a/tests/server-security.test.mjs +++ b/tests/server-security.test.mjs @@ -173,6 +173,7 @@ test("dashboard server functions attach their scoped authorization middleware", "src/features/telegram/groups.functions.ts", "src/features/telegram/users.functions.ts", "src/features/whatsapp/groups.functions.ts", + "src/features/people/people.functions.ts", ] for (const file of adminFunctionFiles) { @@ -183,7 +184,7 @@ test("dashboard server functions attach their scoped authorization middleware", assert.ok(serverFunction.isExported, `${file}:${serverFunction.name} must be exported`) assert.ok( serverFunction.middleware.includes("adminMiddleware") || - serverFunction.middleware.some((name) => /(?:Read|Write|Manage|Create)Middleware$/.test(name)), + serverFunction.middleware.some((name) => /(?:Read|Write|Manage|Create|Self|Appoint)Middleware$/.test(name)), `${file}:${serverFunction.name} must authorize access` ) } From c12724acefefcea26e8d5497eea469440704282e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 12:49:00 +0000 Subject: [PATCH 04/24] feat(people): add People overview and person record Overview table with role, team, campus, account and census filters, and the person record with its cards and activity. Adds Add person / Edit profile, invite, revoke and unlink confirmations, Link account, HR notes and census answers dialogs. On mobile the Overview cards show a labelled line of role chips and the account status instead of bare column values. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ti91VKqKpduLAF376TunMn --- docs/people/plan.md | 7 +- src/components/shell/page-bar.tsx | 5 +- src/features/people/account-action-dialog.tsx | 82 ++ src/features/people/census-answers-dialog.tsx | 71 ++ src/features/people/link-account-dialog.tsx | 166 +++++ src/features/people/notes-dialog.tsx | 62 ++ src/features/people/overview-page.tsx | 446 +++++++++++ src/features/people/people-format.ts | 120 +++ src/features/people/people-hooks.ts | 26 + src/features/people/people-ui.tsx | 231 ++++++ src/features/people/people-write.functions.ts | 48 ++ src/features/people/people.contract.ts | 25 + src/features/people/people.functions.ts | 25 +- src/features/people/person-dialog.tsx | 206 +++++ src/features/people/person-page.tsx | 704 ++++++++++++++++++ src/routes/dashboard/people/$personId.tsx | 24 +- src/routes/dashboard/people/index.tsx | 20 +- tests/server-security.test.mjs | 9 + 18 files changed, 2269 insertions(+), 8 deletions(-) create mode 100644 src/features/people/account-action-dialog.tsx create mode 100644 src/features/people/census-answers-dialog.tsx create mode 100644 src/features/people/link-account-dialog.tsx create mode 100644 src/features/people/notes-dialog.tsx create mode 100644 src/features/people/overview-page.tsx create mode 100644 src/features/people/people-format.ts create mode 100644 src/features/people/people-hooks.ts create mode 100644 src/features/people/people-ui.tsx create mode 100644 src/features/people/people-write.functions.ts create mode 100644 src/features/people/person-dialog.tsx create mode 100644 src/features/people/person-page.tsx diff --git a/docs/people/plan.md b/docs/people/plan.md index 6309f754..ffdba86c 100644 --- a/docs/people/plan.md +++ b/docs/people/plan.md @@ -68,7 +68,7 @@ src/routes/dashboard/account/ me.tsx (My area), index.tsx (today's Account page - [ ] Overview: DataTable with columns, filters (role, team, campus, account, census), search, count, pagination, Send invite + ⋮ row actions, empty states -- [ ] Add person / Edit profile dialog (`FormDialog` lg) +- [x] Add person / Edit profile dialog (`FormDialog` lg) - [ ] Person record: RecordHeader, discrepancy alerts, all cards, Activity; Send / resend invite, revoke, unlink confirmations; HR notes dialog; census answers dialog @@ -97,7 +97,7 @@ src/routes/dashboard/account/ me.tsx (My area), index.tsx (today's Account page - [ ] Needs review queue with Dismiss / Resolve dialog per kind - [ ] Pending invites with Resend / Revoke and expired badge -- [ ] Link account dialog with suggestions, required reason, inline conflict +- [x] Link account dialog with suggestions, required reason, inline conflict (built in phase 2) ### 8. Capo Admin @@ -125,3 +125,6 @@ Newest last. One line per commit or session: date, what was done, what is next. - 2026-10-14: phase 1 done. Verified in agent mode: Direttivo reaches People; `AGENT_PERMISSIONS=people:self` with `AGENT_PERSON=P-0098` (Capo Admin) reaches only Telegram › Capo Admin and Account; with `P-0034` (IT Team Lead) only People › Teams and Account. Next: phase 2 (Overview and person record). +- 2026-10-14: phase 2 done: Overview, person record, Add/Edit person, account confirmations, Link account, HR notes, + census answers. Mobile Overview cards show one line of role chips + account badge instead of unlabelled column + values. The record's Member/Teams/Admin card actions arrive with their phases (3–5). Next: phase 3 (Member register). diff --git a/src/components/shell/page-bar.tsx b/src/components/shell/page-bar.tsx index ce7c2551..747fe616 100644 --- a/src/components/shell/page-bar.tsx +++ b/src/components/shell/page-bar.tsx @@ -94,6 +94,8 @@ export type PageBarProps /** Deep pages: the parent context after the section in the breadcrumb ("Users / 5021007919"). */ context?: ReactNode + /** Deep pages: the parent crumb's label, when it isn't the section's title ("People" over "Overview"). */ + crumb?: string /** Render `context` in DM Mono, for identifiers like Telegram IDs. */ contextMono?: boolean } @@ -128,6 +130,7 @@ export function PageBar({ description, back, context, + crumb, contextMono = false, }: PageBarProps) { const { service, section } = useShellFrame() @@ -146,7 +149,7 @@ export function PageBar({ {section ? (
  • - {section.title} + {crumb ?? section.title}
  • ) : null} diff --git a/src/features/people/account-action-dialog.tsx b/src/features/people/account-action-dialog.tsx new file mode 100644 index 00000000..66841810 --- /dev/null +++ b/src/features/people/account-action-dialog.tsx @@ -0,0 +1,82 @@ +import { useServerFn } from "@tanstack/react-start" +import { Mail, RefreshCw, Unlink, X } from "lucide-react" + +import { ConfirmDialog } from "@/components/primitives" + +import { useRefreshAfter } from "./people-hooks" +import { revokePersonInvite, sendPersonInvite, unlinkPersonAccount } from "./people-write.functions" + +export type AccountAction = { + kind: "invite" | "resend" | "revoke" | "unlink" + personId: string + name: string + /** The invite address, or the linked sign-in for `unlink`. */ + email: string +} + +/** Send, resend or revoke an invite, or unlink an account. Keep `action` after closing so the text survives the exit. */ +export function AccountActionDialog({ + action, + open, + onOpenChange, +}: { + action: AccountAction | null + open: boolean + onOpenChange: (open: boolean) => void +}) { + const send = useServerFn(sendPersonInvite) + const revoke = useServerFn(revokePersonInvite) + const unlink = useServerFn(unlinkPersonAccount) + const refresh = useRefreshAfter() + if (!action) return null + const data = { personId: action.personId } + + if (action.kind === "invite" || action.kind === "resend") { + const resend = action.kind === "resend" + return ( + { + await send({ data }) + await refresh(resend ? "Invite resent." : "Invite sent.") + }} + /> + ) + } + if (action.kind === "revoke") { + return ( + { + await revoke({ data }) + await refresh("Invite revoked.") + }} + /> + ) + } + return ( + { + await unlink({ data }) + await refresh("Account unlinked.") + }} + /> + ) +} diff --git a/src/features/people/census-answers-dialog.tsx b/src/features/people/census-answers-dialog.tsx new file mode 100644 index 00000000..433b3ae8 --- /dev/null +++ b/src/features/people/census-answers-dialog.tsx @@ -0,0 +1,71 @@ +import { useServerFn } from "@tanstack/react-start" +import { useEffect, useState } from "react" + +import { FormDialog, InlineAlert, Spinner } from "@/components/primitives" + +import { getCensusAnswers } from "./people.functions" + +/** A census year's full answers, as the person gave them: HR and the Direttivo only. */ +export function CensusAnswersDialog({ + open, + onOpenChange, + personId, + name, + year, +}: { + open: boolean + onOpenChange: (open: boolean) => void + personId: string + name: string + year: string +}) { + const load = useServerFn(getCensusAnswers) + const [answers, setAnswers] = useState<{ question: string; answer: string }[] | null>(null) + const [failed, setFailed] = useState(false) + + useEffect(() => { + let current = true + load({ data: { personId, year } }).then( + (result) => { + if (current) setAnswers(result) + }, + (caught) => { + console.error(caught) + if (current) setFailed(true) + } + ) + return () => { + current = false + } + }, [load, personId, year]) + + return ( + onOpenChange(false)} + > + {failed ? Couldn't load the answers. Close and try again. : null} + {answers === null && !failed ? ( +

    + + Loading answers… +

    + ) : null} + {answers ? ( +
    + {answers.map((entry) => ( +
    +
    {entry.question}
    +
    {entry.answer}
    +
    + ))} +
    + ) : null} +
    + ) +} diff --git a/src/features/people/link-account-dialog.tsx b/src/features/people/link-account-dialog.tsx new file mode 100644 index 00000000..5ec2ab4b --- /dev/null +++ b/src/features/people/link-account-dialog.tsx @@ -0,0 +1,166 @@ +import { useServerFn } from "@tanstack/react-start" +import { useDeferredValue, useEffect, useState } from "react" + +import { + fieldControl, + fieldHintId, + FormDialog, + FormField, + PlatformGlyph, + Spinner, + StatusBadge, +} from "@/components/primitives" +import { SearchField } from "@/components/shell" +import { Textarea } from "@/components/ui/textarea" +import { cn } from "@/lib/utils" + +import { errorMessage, useRefreshAfter } from "./people-hooks" +import { PersonAvatar } from "./people-ui" +import { linkPersonAccount } from "./people-write.functions" +import type { AccountMatch } from "./people.contract" +import { searchPeopleAccounts } from "./people.functions" + +const LINK_ERROR = "Couldn't link account." + +const REASONS = { + "same-telegram": { label: "Same verified Telegram account", tone: "success" }, + "same-email": { label: "Same email", tone: "success" }, + "similar-name": { label: "Similar name — check before linking", tone: "warning" }, +} as const + +/** Connect a sign-in account to a person's record, with the reason saved in its activity. */ +export function LinkAccountDialog({ + open, + onOpenChange, + person, +}: { + open: boolean + onOpenChange: (open: boolean) => void + person: { id: string; name: string; firstName: string } +}) { + const search = useServerFn(searchPeopleAccounts) + const link = useServerFn(linkPersonAccount) + const refresh = useRefreshAfter() + const [query, setQuery] = useState("") + const deferredQuery = useDeferredValue(query.trim()) + const [accounts, setAccounts] = useState(null) + const [loadError, setLoadError] = useState(null) + const [selected, setSelected] = useState(null) + const [reason, setReason] = useState("") + const [submitted, setSubmitted] = useState(false) + + useEffect(() => { + if (!open) return + let current = true + setLoadError(null) + search({ data: { personId: person.id, query: deferredQuery } }).then( + (result) => { + if (current) setAccounts(result) + }, + (caught) => { + console.error(caught) + if (current) setLoadError("Couldn't load accounts. Try again.") + } + ) + return () => { + current = false + } + }, [open, person.id, deferredQuery, search]) + + const reasonError = submitted && !reason.trim() ? "Enter a reason." : undefined + + async function submit() { + setSubmitted(true) + if (!selected) throw new Error("Choose an account to link.") + if (!reason.trim()) return + try { + await link({ data: { personId: person.id, accountId: selected, reason: reason.trim() } }) + } catch (caught) { + console.error(caught) + throw new Error(caught instanceof Error ? `${LINK_ERROR} ${errorMessage(caught, "")}`.trim() : LINK_ERROR, { + cause: caught, + }) + } + onOpenChange(false) + await refresh("Account linked.") + } + + return ( + +
    + +
    + {deferredQuery ? "Accounts" : "Suggested matches"} + {loadError ?

    {loadError}

    : null} + {accounts === null && !loadError ? : null} + {accounts?.length === 0 ? ( +

    + No accounts match. Check the spelling, or send an invite. +

    + ) : null} + {accounts?.map((account) => ( + + ))} +
    + +